Merge pull request #28751 from nextcloud/feat/28139/profile-page

This commit is contained in:
Pytal 2021-10-18 23:03:58 -07:00 committed by GitHub
commit f7b3d521f8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
141 changed files with 6935 additions and 959 deletions

View file

@ -181,6 +181,11 @@ abstract class AUserData extends OCSController {
IAccountManager::PROPERTY_ADDRESS,
IAccountManager::PROPERTY_WEBSITE,
IAccountManager::PROPERTY_TWITTER,
IAccountManager::PROPERTY_ORGANISATION,
IAccountManager::PROPERTY_ROLE,
IAccountManager::PROPERTY_HEADLINE,
IAccountManager::PROPERTY_BIOGRAPHY,
IAccountManager::PROPERTY_PROFILE_ENABLED,
] as $propertyName) {
$property = $userAccount->getProperty($propertyName);
$data[$propertyName] = $property->getValue();

View file

@ -40,6 +40,7 @@ declare(strict_types=1);
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\Provisioning_API\Controller;
use InvalidArgumentException;
@ -94,29 +95,33 @@ class UsersController extends AUserData {
/** @var IEventDispatcher */
private $eventDispatcher;
public function __construct(string $appName,
IRequest $request,
IUserManager $userManager,
IConfig $config,
IGroupManager $groupManager,
IUserSession $userSession,
IAccountManager $accountManager,
IURLGenerator $urlGenerator,
LoggerInterface $logger,
IFactory $l10nFactory,
NewUserMailHelper $newUserMailHelper,
ISecureRandom $secureRandom,
RemoteWipe $remoteWipe,
KnownUserService $knownUserService,
IEventDispatcher $eventDispatcher) {
parent::__construct($appName,
$request,
$userManager,
$config,
$groupManager,
$userSession,
$accountManager,
$l10nFactory);
public function __construct(
string $appName,
IRequest $request,
IUserManager $userManager,
IConfig $config,
IGroupManager $groupManager,
IUserSession $userSession,
IAccountManager $accountManager,
IURLGenerator $urlGenerator,
LoggerInterface $logger,
IFactory $l10nFactory,
NewUserMailHelper $newUserMailHelper,
ISecureRandom $secureRandom,
RemoteWipe $remoteWipe,
KnownUserService $knownUserService,
IEventDispatcher $eventDispatcher
) {
parent::__construct(
$appName,
$request,
$userManager,
$config,
$groupManager,
$userSession,
$accountManager,
$l10nFactory
);
$this->urlGenerator = $urlGenerator;
$this->logger = $logger;
@ -325,14 +330,16 @@ class UsersController extends AUserData {
* @return DataResponse
* @throws OCSException
*/
public function addUser(string $userid,
string $password = '',
string $displayName = '',
string $email = '',
array $groups = [],
array $subadmin = [],
string $quota = '',
string $language = ''): DataResponse {
public function addUser(
string $userid,
string $password = '',
string $displayName = '',
string $email = '',
array $groups = [],
array $subadmin = [],
string $quota = '',
string $language = ''
): DataResponse {
$user = $this->userSession->getUser();
$isAdmin = $this->groupManager->isAdmin($user->getUID());
$subAdminManager = $this->groupManager->getSubAdmin();
@ -349,10 +356,10 @@ class UsersController extends AUserData {
if ($groups !== []) {
foreach ($groups as $group) {
if (!$this->groupManager->groupExists($group)) {
throw new OCSException('group '.$group.' does not exist', 104);
throw new OCSException('group ' . $group . ' does not exist', 104);
}
if (!$isAdmin && !$subAdminManager->isSubAdminOfGroup($user, $this->groupManager->get($group))) {
throw new OCSException('insufficient privileges for group '. $group, 105);
throw new OCSException('insufficient privileges for group ' . $group, 105);
}
}
} else {
@ -440,7 +447,8 @@ class UsersController extends AUserData {
} catch (\Exception $e) {
// Mail could be failing hard or just be plain not configured
// Logging error as it is the hardest of the two
$this->logger->error("Unable to send the invitation mail to $email",
$this->logger->error(
"Unable to send the invitation mail to $email",
[
'app' => 'ocs_api',
'exception' => $e,
@ -452,7 +460,8 @@ class UsersController extends AUserData {
return new DataResponse(['id' => $userid]);
} catch (HintException $e) {
$this->logger->warning('Failed addUser attempt with hint exception.',
$this->logger->warning(
'Failed addUser attempt with hint exception.',
[
'app' => 'ocs_api',
'exception' => $e,
@ -460,7 +469,8 @@ class UsersController extends AUserData {
);
throw new OCSException($e->getHint(), 107);
} catch (OCSException $e) {
$this->logger->warning('Failed addUser attempt with ocs exeption.',
$this->logger->warning(
'Failed addUser attempt with ocs exeption.',
[
'app' => 'ocs_api',
'exception' => $e,
@ -468,7 +478,8 @@ class UsersController extends AUserData {
);
throw $e;
} catch (InvalidArgumentException $e) {
$this->logger->error('Failed addUser attempt with invalid argument exeption.',
$this->logger->error(
'Failed addUser attempt with invalid argument exeption.',
[
'app' => 'ocs_api',
'exception' => $e,
@ -476,7 +487,8 @@ class UsersController extends AUserData {
);
throw new OCSException($e->getMessage(), 101);
} catch (\Exception $e) {
$this->logger->error('Failed addUser attempt with exception.',
$this->logger->error(
'Failed addUser attempt with exception.',
[
'app' => 'ocs_api',
'exception' => $e
@ -573,8 +585,10 @@ class UsersController extends AUserData {
}
$subAdminManager = $this->groupManager->getSubAdmin();
if (!$this->groupManager->isAdmin($currentLoggedInUser->getUID())
&& !$subAdminManager->isUserAccessible($currentLoggedInUser, $targetUser)) {
if (
!$this->groupManager->isAdmin($currentLoggedInUser->getUID())
&& !$subAdminManager->isUserAccessible($currentLoggedInUser, $targetUser)
) {
throw new OCSException('', OCSController::RESPOND_NOT_FOUND);
}
} else {
@ -583,8 +597,10 @@ class UsersController extends AUserData {
// Editing self (display, email)
if ($this->config->getSystemValue('allow_user_to_change_display_name', true) !== false) {
if ($targetUser->getBackend() instanceof ISetDisplayNameBackend
|| $targetUser->getBackend()->implementsActions(Backend::SET_DISPLAYNAME)) {
if (
$targetUser->getBackend() instanceof ISetDisplayNameBackend
|| $targetUser->getBackend()->implementsActions(Backend::SET_DISPLAYNAME)
) {
$permittedFields[] = IAccountManager::PROPERTY_DISPLAYNAME;
}
$permittedFields[] = IAccountManager::PROPERTY_EMAIL;
@ -595,6 +611,11 @@ class UsersController extends AUserData {
$permittedFields[] = IAccountManager::PROPERTY_ADDRESS;
$permittedFields[] = IAccountManager::PROPERTY_WEBSITE;
$permittedFields[] = IAccountManager::PROPERTY_TWITTER;
$permittedFields[] = IAccountManager::PROPERTY_ORGANISATION;
$permittedFields[] = IAccountManager::PROPERTY_ROLE;
$permittedFields[] = IAccountManager::PROPERTY_HEADLINE;
$permittedFields[] = IAccountManager::PROPERTY_BIOGRAPHY;
$permittedFields[] = IAccountManager::PROPERTY_PROFILE_ENABLED;
return new DataResponse($permittedFields);
}
@ -716,8 +737,10 @@ class UsersController extends AUserData {
if ($targetUser->getUID() === $currentLoggedInUser->getUID()) {
// Editing self (display, email)
if ($this->config->getSystemValue('allow_user_to_change_display_name', true) !== false) {
if ($targetUser->getBackend() instanceof ISetDisplayNameBackend
|| $targetUser->getBackend()->implementsActions(Backend::SET_DISPLAYNAME)) {
if (
$targetUser->getBackend() instanceof ISetDisplayNameBackend
|| $targetUser->getBackend()->implementsActions(Backend::SET_DISPLAYNAME)
) {
$permittedFields[] = self::USER_FIELD_DISPLAYNAME;
$permittedFields[] = IAccountManager::PROPERTY_DISPLAYNAME;
}
@ -731,13 +754,17 @@ class UsersController extends AUserData {
$permittedFields[] = self::USER_FIELD_PASSWORD;
$permittedFields[] = self::USER_FIELD_NOTIFICATION_EMAIL;
if ($this->config->getSystemValue('force_language', false) === false ||
$this->groupManager->isAdmin($currentLoggedInUser->getUID())) {
if (
$this->config->getSystemValue('force_language', false) === false ||
$this->groupManager->isAdmin($currentLoggedInUser->getUID())
) {
$permittedFields[] = self::USER_FIELD_LANGUAGE;
}
if ($this->config->getSystemValue('force_locale', false) === false ||
$this->groupManager->isAdmin($currentLoggedInUser->getUID())) {
if (
$this->config->getSystemValue('force_locale', false) === false ||
$this->groupManager->isAdmin($currentLoggedInUser->getUID())
) {
$permittedFields[] = self::USER_FIELD_LOCALE;
}
@ -745,10 +772,20 @@ class UsersController extends AUserData {
$permittedFields[] = IAccountManager::PROPERTY_ADDRESS;
$permittedFields[] = IAccountManager::PROPERTY_WEBSITE;
$permittedFields[] = IAccountManager::PROPERTY_TWITTER;
$permittedFields[] = IAccountManager::PROPERTY_ORGANISATION;
$permittedFields[] = IAccountManager::PROPERTY_ROLE;
$permittedFields[] = IAccountManager::PROPERTY_HEADLINE;
$permittedFields[] = IAccountManager::PROPERTY_BIOGRAPHY;
$permittedFields[] = IAccountManager::PROPERTY_PROFILE_ENABLED;
$permittedFields[] = IAccountManager::PROPERTY_PHONE . self::SCOPE_SUFFIX;
$permittedFields[] = IAccountManager::PROPERTY_ADDRESS . self::SCOPE_SUFFIX;
$permittedFields[] = IAccountManager::PROPERTY_WEBSITE . self::SCOPE_SUFFIX;
$permittedFields[] = IAccountManager::PROPERTY_TWITTER . self::SCOPE_SUFFIX;
$permittedFields[] = IAccountManager::PROPERTY_ORGANISATION . self::SCOPE_SUFFIX;
$permittedFields[] = IAccountManager::PROPERTY_ROLE . self::SCOPE_SUFFIX;
$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_AVATAR . self::SCOPE_SUFFIX;
@ -759,11 +796,15 @@ class UsersController extends AUserData {
} else {
// Check if admin / subadmin
$subAdminManager = $this->groupManager->getSubAdmin();
if ($this->groupManager->isAdmin($currentLoggedInUser->getUID())
|| $subAdminManager->isUserAccessible($currentLoggedInUser, $targetUser)) {
if (
$this->groupManager->isAdmin($currentLoggedInUser->getUID())
|| $subAdminManager->isUserAccessible($currentLoggedInUser, $targetUser)
) {
// They have permissions over the user
if ($targetUser->getBackend() instanceof ISetDisplayNameBackend
|| $targetUser->getBackend()->implementsActions(Backend::SET_DISPLAYNAME)) {
if (
$targetUser->getBackend() instanceof ISetDisplayNameBackend
|| $targetUser->getBackend()->implementsActions(Backend::SET_DISPLAYNAME)
) {
$permittedFields[] = self::USER_FIELD_DISPLAYNAME;
$permittedFields[] = IAccountManager::PROPERTY_DISPLAYNAME;
}
@ -776,6 +817,11 @@ class UsersController extends AUserData {
$permittedFields[] = IAccountManager::PROPERTY_ADDRESS;
$permittedFields[] = IAccountManager::PROPERTY_WEBSITE;
$permittedFields[] = IAccountManager::PROPERTY_TWITTER;
$permittedFields[] = IAccountManager::PROPERTY_ORGANISATION;
$permittedFields[] = IAccountManager::PROPERTY_ROLE;
$permittedFields[] = IAccountManager::PROPERTY_HEADLINE;
$permittedFields[] = IAccountManager::PROPERTY_BIOGRAPHY;
$permittedFields[] = IAccountManager::PROPERTY_PROFILE_ENABLED;
$permittedFields[] = self::USER_FIELD_QUOTA;
$permittedFields[] = self::USER_FIELD_NOTIFICATION_EMAIL;
} else {
@ -802,7 +848,7 @@ class UsersController extends AUserData {
$quota = \OCP\Util::computerFileSize($quota);
}
if ($quota === false) {
throw new OCSException('Invalid quota value '.$value, 102);
throw new OCSException('Invalid quota value ' . $value, 102);
}
if ($quota === -1) {
$quota = 'none';
@ -892,6 +938,10 @@ class UsersController extends AUserData {
case IAccountManager::PROPERTY_ADDRESS:
case IAccountManager::PROPERTY_WEBSITE:
case IAccountManager::PROPERTY_TWITTER:
case IAccountManager::PROPERTY_ORGANISATION:
case IAccountManager::PROPERTY_ROLE:
case IAccountManager::PROPERTY_HEADLINE:
case IAccountManager::PROPERTY_BIOGRAPHY:
$userAccount = $this->accountManager->getAccount($targetUser);
try {
$userProperty = $userAccount->getProperty($key);
@ -910,12 +960,29 @@ class UsersController extends AUserData {
}
$this->accountManager->updateAccount($userAccount);
break;
case IAccountManager::PROPERTY_PROFILE_ENABLED:
$userAccount = $this->accountManager->getAccount($targetUser);
try {
$userProperty = $userAccount->getProperty($key);
if ($userProperty->getValue() !== $value) {
$userProperty->setValue($value);
}
} catch (PropertyDoesNotExistException $e) {
$userAccount->setProperty($key, $value, IAccountManager::SCOPE_LOCAL, IAccountManager::NOT_VERIFIED);
}
$this->accountManager->updateAccount($userAccount);
break;
case IAccountManager::PROPERTY_DISPLAYNAME . self::SCOPE_SUFFIX:
case IAccountManager::PROPERTY_EMAIL . self::SCOPE_SUFFIX:
case IAccountManager::PROPERTY_PHONE . self::SCOPE_SUFFIX:
case IAccountManager::PROPERTY_ADDRESS . self::SCOPE_SUFFIX:
case IAccountManager::PROPERTY_WEBSITE . self::SCOPE_SUFFIX:
case IAccountManager::PROPERTY_TWITTER . self::SCOPE_SUFFIX:
case IAccountManager::PROPERTY_ORGANISATION . self::SCOPE_SUFFIX:
case IAccountManager::PROPERTY_ROLE . self::SCOPE_SUFFIX:
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_AVATAR . self::SCOPE_SUFFIX:
$propertyName = substr($key, 0, strlen($key) - strlen(self::SCOPE_SUFFIX));
$userAccount = $this->accountManager->getAccount($targetUser);
@ -1300,8 +1367,10 @@ class UsersController extends AUserData {
// Check if admin / subadmin
$subAdminManager = $this->groupManager->getSubAdmin();
if (!$subAdminManager->isUserAccessible($currentLoggedInUser, $targetUser)
&& !$this->groupManager->isAdmin($currentLoggedInUser->getUID())) {
if (
!$subAdminManager->isUserAccessible($currentLoggedInUser, $targetUser)
&& !$this->groupManager->isAdmin($currentLoggedInUser->getUID())
) {
// No rights
throw new OCSException('', OCSController::RESPOND_NOT_FOUND);
}
@ -1315,7 +1384,8 @@ class UsersController extends AUserData {
$emailTemplate = $this->newUserMailHelper->generateTemplate($targetUser, false);
$this->newUserMailHelper->sendMail($targetUser, $emailTemplate);
} catch (\Exception $e) {
$this->logger->error("Can't send new user mail to $email",
$this->logger->error(
"Can't send new user mail to $email",
[
'app' => 'settings',
'exception' => $e,

View file

@ -1,4 +1,5 @@
<?php
/**
* @copyright Copyright (c) 2016, ownCloud, Inc.
*
@ -38,6 +39,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\Provisioning_API\Tests\Controller;
use Exception;
@ -165,11 +167,12 @@ class UsersControllerTest extends TestCase {
->with('MyCustomSearch')
->willReturn(['Admin' => [], 'Foo' => [], 'Bar' => []]);
$expected = ['users' => [
'Admin',
'Foo',
'Bar',
],
$expected = [
'users' => [
'Admin',
'Foo',
'Bar',
],
];
$this->assertEquals($expected, $this->api->getUsers('MyCustomSearch')->getData());
}
@ -687,7 +690,7 @@ class UsersControllerTest extends TestCase {
$this->assertTrue(key_exists(
'id',
$this->api->addUser('NewUser', 'PasswordOfTheNewUser', '', '', ['ExistingGroup'])->getData()
$this->api->addUser('NewUser', 'PasswordOfTheNewUser', '', '', ['ExistingGroup'])->getData()
));
}
@ -711,7 +714,8 @@ class UsersControllerTest extends TestCase {
$this->logger
->expects($this->once())
->method('error')
->with('Failed addUser attempt with exception.',
->with(
'Failed addUser attempt with exception.',
[
'app' => 'ocs_api',
'exception' => $exception
@ -998,6 +1002,11 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_PHONE => ['value' => 'phone'],
IAccountManager::PROPERTY_TWITTER => ['value' => 'twitter'],
IAccountManager::PROPERTY_WEBSITE => ['value' => 'website'],
IAccountManager::PROPERTY_ORGANISATION => ['value' => 'organisation'],
IAccountManager::PROPERTY_ROLE => ['value' => 'role'],
IAccountManager::PROPERTY_HEADLINE => ['value' => 'headline'],
IAccountManager::PROPERTY_BIOGRAPHY => ['value' => 'biography'],
IAccountManager::PROPERTY_PROFILE_ENABLED => ['value' => '1'],
]);
$this->config
->expects($this->at(0))
@ -1067,6 +1076,11 @@ class UsersControllerTest extends TestCase {
'setPassword' => true,
],
'additional_mail' => [],
'organisation' => 'organisation',
'role' => 'role',
'headline' => 'headline',
'biography' => 'biography',
'profile_enabled' => '1',
'notify_email' => null,
];
$this->assertEquals($expected, $this->invokePrivate($this->api, 'getUserData', ['UID']));
@ -1166,6 +1180,11 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_PHONE => ['value' => 'phone'],
IAccountManager::PROPERTY_TWITTER => ['value' => 'twitter'],
IAccountManager::PROPERTY_WEBSITE => ['value' => 'website'],
IAccountManager::PROPERTY_ORGANISATION => ['value' => 'organisation'],
IAccountManager::PROPERTY_ROLE => ['value' => 'role'],
IAccountManager::PROPERTY_HEADLINE => ['value' => 'headline'],
IAccountManager::PROPERTY_BIOGRAPHY => ['value' => 'biography'],
IAccountManager::PROPERTY_PROFILE_ENABLED => ['value' => '1'],
]);
$this->l10nFactory
@ -1196,6 +1215,11 @@ class UsersControllerTest extends TestCase {
'setPassword' => true,
],
'additional_mail' => [],
'organisation' => 'organisation',
'role' => 'role',
'headline' => 'headline',
'biography' => 'biography',
'profile_enabled' => '1',
'notify_email' => null,
];
$this->assertEquals($expected, $this->invokePrivate($this->api, 'getUserData', ['UID']));
@ -1334,6 +1358,11 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_PHONE => ['value' => 'phone'],
IAccountManager::PROPERTY_TWITTER => ['value' => 'twitter'],
IAccountManager::PROPERTY_WEBSITE => ['value' => 'website'],
IAccountManager::PROPERTY_ORGANISATION => ['value' => 'organisation'],
IAccountManager::PROPERTY_ROLE => ['value' => 'role'],
IAccountManager::PROPERTY_HEADLINE => ['value' => 'headline'],
IAccountManager::PROPERTY_BIOGRAPHY => ['value' => 'biography'],
IAccountManager::PROPERTY_PROFILE_ENABLED => ['value' => '1'],
]);
$this->l10nFactory
@ -1363,6 +1392,11 @@ class UsersControllerTest extends TestCase {
'setPassword' => false,
],
'additional_mail' => [],
'organisation' => 'organisation',
'role' => 'role',
'headline' => 'headline',
'biography' => 'biography',
'profile_enabled' => '1',
'notify_email' => null,
];
$this->assertEquals($expected, $this->invokePrivate($this->api, 'getUserData', ['UID']));
@ -1543,6 +1577,11 @@ class UsersControllerTest extends TestCase {
[IAccountManager::PROPERTY_PHONE, '1234', '12345'],
[IAccountManager::PROPERTY_ADDRESS, 'Something street 2', 'Another street 3'],
[IAccountManager::PROPERTY_WEBSITE, 'https://examplesite1', 'https://examplesite2'],
[IAccountManager::PROPERTY_ORGANISATION, 'Organisation A', 'Organisation B'],
[IAccountManager::PROPERTY_ROLE, 'Human', 'Alien'],
[IAccountManager::PROPERTY_HEADLINE, 'Hi', 'Hello'],
[IAccountManager::PROPERTY_BIOGRAPHY, 'A biography', 'Another biography'],
[IAccountManager::PROPERTY_PROFILE_ENABLED, '1', '0'],
];
}
@ -1614,6 +1653,11 @@ class UsersControllerTest extends TestCase {
[IAccountManager::PROPERTY_PHONE, IAccountManager::SCOPE_LOCAL, IAccountManager::SCOPE_FEDERATED],
[IAccountManager::PROPERTY_ADDRESS, IAccountManager::SCOPE_LOCAL, IAccountManager::SCOPE_FEDERATED],
[IAccountManager::PROPERTY_WEBSITE, IAccountManager::SCOPE_LOCAL, IAccountManager::SCOPE_FEDERATED],
[IAccountManager::PROPERTY_ORGANISATION, IAccountManager::SCOPE_LOCAL, IAccountManager::SCOPE_FEDERATED],
[IAccountManager::PROPERTY_ROLE, IAccountManager::SCOPE_LOCAL, IAccountManager::SCOPE_FEDERATED],
[IAccountManager::PROPERTY_HEADLINE, IAccountManager::SCOPE_LOCAL, IAccountManager::SCOPE_FEDERATED],
[IAccountManager::PROPERTY_BIOGRAPHY, IAccountManager::SCOPE_LOCAL, IAccountManager::SCOPE_FEDERATED],
[IAccountManager::PROPERTY_PROFILE_ENABLED, IAccountManager::SCOPE_LOCAL, IAccountManager::SCOPE_FEDERATED],
];
}
@ -3490,7 +3534,12 @@ class UsersControllerTest extends TestCase {
'phone' => 'phone',
'address' => 'address',
'website' => 'website',
'twitter' => 'twitter'
'twitter' => 'twitter',
'organisation' => 'organisation',
'role' => 'role',
'headline' => 'headline',
'biography' => 'biography',
'profile_enabled' => '1'
]
);
@ -3503,6 +3552,11 @@ class UsersControllerTest extends TestCase {
'address' => 'address',
'website' => 'website',
'twitter' => 'twitter',
'organisation' => 'organisation',
'role' => 'role',
'headline' => 'headline',
'biography' => 'biography',
'profile_enabled' => '1',
'display-name' => 'Demo User'
];
@ -3560,7 +3614,12 @@ class UsersControllerTest extends TestCase {
'address' => 'address',
'website' => 'website',
'twitter' => 'twitter',
'displayname' => 'Demo User'
'displayname' => 'Demo User',
'organisation' => 'organisation',
'role' => 'role',
'headline' => 'headline',
'biography' => 'biography',
'profile_enabled' => '1'
];
$api->expects($this->at(0))->method('getUserData')
@ -3878,6 +3937,11 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_ADDRESS,
IAccountManager::PROPERTY_WEBSITE,
IAccountManager::PROPERTY_TWITTER,
IAccountManager::PROPERTY_ORGANISATION,
IAccountManager::PROPERTY_ROLE,
IAccountManager::PROPERTY_HEADLINE,
IAccountManager::PROPERTY_BIOGRAPHY,
IAccountManager::PROPERTY_PROFILE_ENABLED,
]],
[true, ISetDisplayNameBackend::class, [
IAccountManager::PROPERTY_DISPLAYNAME,
@ -3887,6 +3951,11 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_ADDRESS,
IAccountManager::PROPERTY_WEBSITE,
IAccountManager::PROPERTY_TWITTER,
IAccountManager::PROPERTY_ORGANISATION,
IAccountManager::PROPERTY_ROLE,
IAccountManager::PROPERTY_HEADLINE,
IAccountManager::PROPERTY_BIOGRAPHY,
IAccountManager::PROPERTY_PROFILE_ENABLED,
]],
[true, UserInterface::class, [
IAccountManager::PROPERTY_EMAIL,
@ -3895,6 +3964,11 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_ADDRESS,
IAccountManager::PROPERTY_WEBSITE,
IAccountManager::PROPERTY_TWITTER,
IAccountManager::PROPERTY_ORGANISATION,
IAccountManager::PROPERTY_ROLE,
IAccountManager::PROPERTY_HEADLINE,
IAccountManager::PROPERTY_BIOGRAPHY,
IAccountManager::PROPERTY_PROFILE_ENABLED,
]],
];
}
@ -3941,7 +4015,7 @@ class UsersControllerTest extends TestCase {
$account = $this->createMock(IAccount::class);
$account->method('getProperty')
->will($this->returnValueMap($mockedProperties));
->will($this->returnValueMap($mockedProperties));
$this->accountManager->expects($this->any())->method('getAccount')
->with($targetUser)

View file

@ -107,14 +107,20 @@ input {
#personal-settings-avatar-container {
display: inline-grid;
grid-template-columns: 1fr;
grid-template-rows: 2fr 1fr;
grid-template-rows: 2fr 1fr 2fr;
vertical-align: top;
}
.profile-settings-container {
display: inline-grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr 2fr 1fr;
grid-template-rows: 1fr 1fr 1fr 2fr;
#locale {
h3 {
height: 32px;
}
}
}
.personal-show-container {
@ -217,7 +223,7 @@ select {
.personal-settings-container {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr 1fr;
}
.profile-settings-container {
@ -239,7 +245,7 @@ select {
.personal-settings-container {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr 1fr;
}
.profile-settings-container {
@ -273,7 +279,7 @@ select {
.personal-settings-container {
display: inline-grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr 2fr;
grid-template-rows: 1fr 1fr 1fr 1fr 1fr;
&:after {
clear: both;

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -33,13 +33,16 @@ declare(strict_types=1);
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Settings\Settings\Personal;
use OCA\FederatedFileSharing\FederatedShareProvider;
use OCP\Accounts\IAccount;
use OCP\Accounts\IAccountManager;
use OCP\Accounts\IAccountProperty;
use OCP\App\IAppManager;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Files\FileInfo;
use OCP\IConfig;
use OCP\IGroup;
@ -48,26 +51,36 @@ use OCP\IL10N;
use OCP\IUser;
use OCP\IUserManager;
use OCP\L10N\IFactory;
use OC\Profile\ProfileManager;
use OCP\Settings\ISettings;
use OCP\Accounts\IAccountProperty;
use OCP\AppFramework\Services\IInitialState;
class PersonalInfo implements ISettings {
use \OC\Profile\TProfileHelper;
/** @var IConfig */
private $config;
/** @var IUserManager */
private $userManager;
/** @var IAccountManager */
private $accountManager;
/** @var ProfileManager */
private $profileManager;
/** @var IGroupManager */
private $groupManager;
/** @var IAppManager */
private $appManager;
/** @var IFactory */
private $l10nFactory;
/** @var IL10N */
private $l;
/** @var IInitialState */
private $initialStateService;
@ -76,6 +89,7 @@ class PersonalInfo implements ISettings {
IUserManager $userManager,
IGroupManager $groupManager,
IAccountManager $accountManager,
ProfileManager $profileManager,
IAppManager $appManager,
IFactory $l10nFactory,
IL10N $l,
@ -84,6 +98,7 @@ class PersonalInfo implements ISettings {
$this->config = $config;
$this->userManager = $userManager;
$this->accountManager = $accountManager;
$this->profileManager = $profileManager;
$this->groupManager = $groupManager;
$this->appManager = $appManager;
$this->l10nFactory = $l10nFactory;
@ -114,7 +129,7 @@ class PersonalInfo implements ISettings {
$totalSpace = \OC_Helper::humanFileSize($storageInfo['total']);
}
$languageParameters = $this->getLanguages($user);
$languageParameters = $this->getLanguageMap($user);
$localeParameters = $this->getLocales($user);
$messageParameters = $this->getMessageParameters($account);
@ -146,9 +161,15 @@ class PersonalInfo implements ISettings {
] + $messageParameters + $languageParameters + $localeParameters;
$personalInfoParameters = [
'displayNames' => $this->getDisplayNames($account),
'emails' => $this->getEmails($account),
'languages' => $this->getLanguages($user),
'userId' => $uid,
'displayNameMap' => $this->getDisplayNameMap($account),
'emailMap' => $this->getEmailMap($account),
'languageMap' => $this->getLanguageMap($user),
'profileEnabled' => $this->isProfileEnabled($account),
'organisationMap' => $this->getOrganisationMap($account),
'roleMap' => $this->getRoleMap($account),
'headlineMap' => $this->getHeadlineMap($account),
'biographyMap' => $this->getBiographyMap($account),
];
$accountParameters = [
@ -156,14 +177,91 @@ class PersonalInfo implements ISettings {
'lookupServerUploadEnabled' => $lookupServerUploadEnabled,
];
$profileParameters = [
'profileConfig' => $this->profileManager->getProfileConfig($user, $user),
];
$this->initialStateService->provideInitialState('personalInfoParameters', $personalInfoParameters);
$this->initialStateService->provideInitialState('accountParameters', $accountParameters);
$this->initialStateService->provideInitialState('profileParameters', $profileParameters);
return new TemplateResponse('settings', 'settings/personal/personal.info', $parameters, '');
}
/**
* @return string the section ID, e.g. 'sharing'
* returns the primary biography in an
* associative array
*/
private function getBiographyMap(IAccount $account): array {
$primaryBiography = [
'value' => $account->getProperty(IAccountManager::PROPERTY_BIOGRAPHY)->getValue(),
'scope' => $account->getProperty(IAccountManager::PROPERTY_BIOGRAPHY)->getScope(),
'verified' => $account->getProperty(IAccountManager::PROPERTY_BIOGRAPHY)->getVerified(),
];
$biographyMap = [
'primaryBiography' => $primaryBiography,
];
return $biographyMap;
}
/**
* returns the primary organisation in an
* associative array
*/
private function getOrganisationMap(IAccount $account): array {
$primaryOrganisation = [
'value' => $account->getProperty(IAccountManager::PROPERTY_ORGANISATION)->getValue(),
'scope' => $account->getProperty(IAccountManager::PROPERTY_ORGANISATION)->getScope(),
'verified' => $account->getProperty(IAccountManager::PROPERTY_ORGANISATION)->getVerified(),
];
$organisationMap = [
'primaryOrganisation' => $primaryOrganisation,
];
return $organisationMap;
}
/**
* returns the primary headline in an
* associative array
*/
private function getHeadlineMap(IAccount $account): array {
$primaryHeadline = [
'value' => $account->getProperty(IAccountManager::PROPERTY_HEADLINE)->getValue(),
'scope' => $account->getProperty(IAccountManager::PROPERTY_HEADLINE)->getScope(),
'verified' => $account->getProperty(IAccountManager::PROPERTY_HEADLINE)->getVerified(),
];
$headlineMap = [
'primaryHeadline' => $primaryHeadline,
];
return $headlineMap;
}
/**
* returns the primary role in an
* associative array
*/
private function getRoleMap(IAccount $account): array {
$primaryRole = [
'value' => $account->getProperty(IAccountManager::PROPERTY_ROLE)->getValue(),
'scope' => $account->getProperty(IAccountManager::PROPERTY_ROLE)->getScope(),
'verified' => $account->getProperty(IAccountManager::PROPERTY_ROLE)->getVerified(),
];
$roleMap = [
'primaryRole' => $primaryRole,
];
return $roleMap;
}
/**
* returns the section ID string, e.g. 'sharing'
* @since 9.1
*/
public function getSection(): string {
@ -184,9 +282,6 @@ class PersonalInfo implements ISettings {
/**
* returns a sorted list of the user's group GIDs
*
* @param IUser $user
* @return array
*/
private function getGroups(IUser $user): array {
$groups = array_map(
@ -205,32 +300,26 @@ class PersonalInfo implements ISettings {
* associative array
*
* NOTE may be extended to provide additional display names (i.e. aliases) in the future
*
* @param IAccount $account
* @return array
*/
private function getDisplayNames(IAccount $account): array {
private function getDisplayNameMap(IAccount $account): array {
$primaryDisplayName = [
'value' => $account->getProperty(IAccountManager::PROPERTY_DISPLAYNAME)->getValue(),
'scope' => $account->getProperty(IAccountManager::PROPERTY_DISPLAYNAME)->getScope(),
'verified' => $account->getProperty(IAccountManager::PROPERTY_DISPLAYNAME)->getVerified(),
];
$displayNames = [
$displayNameMap = [
'primaryDisplayName' => $primaryDisplayName,
];
return $displayNames;
return $displayNameMap;
}
/**
* returns the primary email and additional emails in an
* associative array
*
* @param IAccount $account
* @return array
*/
private function getEmails(IAccount $account): array {
private function getEmailMap(IAccount $account): array {
$systemEmail = [
'value' => $account->getProperty(IAccountManager::PROPERTY_EMAIL)->getValue(),
'scope' => $account->getProperty(IAccountManager::PROPERTY_EMAIL)->getScope(),
@ -246,26 +335,23 @@ class PersonalInfo implements ISettings {
'locallyVerified' => $property->getLocallyVerified(),
];
},
$account->getPropertyCollection(IAccountManager::COLLECTION_EMAIL)->getProperties()
$account->getPropertyCollection(IAccountManager::COLLECTION_EMAIL)->getProperties(),
);
$emails = [
$emailMap = [
'primaryEmail' => $systemEmail,
'additionalEmails' => $additionalEmails,
'notificationEmail' => (string)$account->getUser()->getPrimaryEMailAddress(),
];
return $emails;
return $emailMap;
}
/**
* returns the user's active language, common languages, and other languages in an
* associative array
*
* @param IUser $user
* @return array
*/
private function getLanguages(IUser $user): array {
private function getLanguageMap(IUser $user): array {
$forceLanguage = $this->config->getSystemValue('force_language', false);
if ($forceLanguage !== false) {
return [];
@ -340,8 +426,7 @@ class PersonalInfo implements ISettings {
}
/**
* @param IAccount $account
* @return array
* returns the message parameters
*/
private function getMessageParameters(IAccount $account): array {
$needVerifyMessage = [IAccountManager::PROPERTY_EMAIL, IAccountManager::PROPERTY_WEBSITE, IAccountManager::PROPERTY_TWITTER];

View file

@ -0,0 +1,182 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="biography">
<textarea
id="biography"
:placeholder="t('settings', 'Your biography')"
:value="biography"
rows="8"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
@input="onBiographyChange" />
<div class="biography__actions-container">
<transition name="fade">
<span v-if="showCheckmarkIcon" class="icon-checkmark" />
<span v-else-if="showErrorIcon" class="icon-error" />
</transition>
</div>
</div>
</template>
<script>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import debounce from 'debounce'
import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
export default {
name: 'Biography',
props: {
biography: {
type: String,
required: true,
},
scope: {
type: String,
required: true,
},
},
data() {
return {
initialBiography: this.biography,
localScope: this.scope,
showCheckmarkIcon: false,
showErrorIcon: false,
}
},
methods: {
onBiographyChange(e) {
this.$emit('update:biography', e.target.value)
this.debounceBiographyChange(e.target.value.trim())
},
debounceBiographyChange: debounce(async function(biography) {
await this.updatePrimaryBiography(biography)
}, 500),
async updatePrimaryBiography(biography) {
try {
const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.BIOGRAPHY, biography)
this.handleResponse({
biography,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update biography'),
error: e,
})
}
},
handleResponse({ biography, status, errorMessage, error }) {
if (status === 'ok') {
// Ensure that local state reflects server state
this.initialBiography = biography
emit('settings:biography:updated', biography)
this.showCheckmarkIcon = true
setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
} else {
showError(errorMessage)
this.logger.error(errorMessage, error)
this.showErrorIcon = true
setTimeout(() => { this.showErrorIcon = false }, 2000)
}
},
onScopeChange(scope) {
this.$emit('update:scope', scope)
},
},
}
</script>
<style lang="scss" scoped>
.biography {
display: grid;
align-items: center;
textarea {
resize: none;
grid-area: 1 / 1;
width: 100%;
margin: 3px 3px 3px 0;
padding: 7px 6px;
color: var(--color-main-text);
border: 1px solid var(--color-border-dark);
border-radius: var(--border-radius);
background-color: var(--color-main-background);
font-family: var(--font-face);
cursor: text;
&:hover {
border-color: var(--color-primary-element) !important;
outline: none !important;
}
}
.biography__actions-container {
grid-area: 1 / 1;
justify-self: flex-end;
align-self: flex-end;
height: 30px;
display: flex;
gap: 0 2px;
margin-right: 5px;
margin-bottom: 5px;
.icon-checkmark,
.icon-error {
height: 30px !important;
min-height: 30px !important;
width: 30px !important;
min-width: 30px !important;
top: 0;
right: 0;
float: none;
}
}
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
.fade-enter-active {
transition: opacity 200ms ease-out;
}
.fade-leave-active {
transition: opacity 300ms ease-out;
}
</style>

View file

@ -0,0 +1,81 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<section>
<HeaderBar
:account-property="accountProperty"
label-for="biography"
:scope.sync="primaryBiography.scope" />
<Biography
:biography.sync="primaryBiography.value"
:scope.sync="primaryBiography.scope" />
<VisibilityDropdown
:param-id="accountPropertyId"
:display-id="accountProperty"
:visibility.sync="visibility" />
</section>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import Biography from './Biography'
import HeaderBar from '../shared/HeaderBar'
import VisibilityDropdown from '../shared/VisibilityDropdown'
import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
const { biographyMap: { primaryBiography } } = loadState('settings', 'personalInfoParameters', {})
const { profileConfig: { biography: { visibility } } } = loadState('settings', 'profileParameters', {})
export default {
name: 'BiographySection',
components: {
Biography,
HeaderBar,
VisibilityDropdown,
},
data() {
return {
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.BIOGRAPHY,
accountPropertyId: ACCOUNT_PROPERTY_ENUM.BIOGRAPHY,
primaryBiography,
visibility,
}
},
}
</script>
<style lang="scss" scoped>
section {
padding: 10px 10px;
&::v-deep button:disabled {
cursor: default;
}
}
</style>

View file

@ -17,21 +17,19 @@
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="displayname">
<input
id="displayname"
ref="displayName"
type="text"
name="displayname"
:placeholder="t('settings', 'Your full name')"
:value="displayName"
autocapitalize="none"
autocomplete="on"
autocorrect="off"
required
@input="onDisplayNameChange">
<div class="displayname__actions-container">
@ -45,10 +43,12 @@
<script>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import debounce from 'debounce'
import { savePrimaryDisplayName } from '../../../service/PersonalInfo/DisplayNameService'
import { validateDisplayName } from '../../../utils/validate'
import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
import { validateStringInput } from '../../../utils/validate'
// TODO Global avatar updating on events (e.g. updating the displayname) is currently being handled by global js, investigate using https://github.com/nextcloud/nextcloud-event-bus for global avatar updating
@ -82,21 +82,21 @@ export default {
},
debounceDisplayNameChange: debounce(async function(displayName) {
if (validateDisplayName(displayName)) {
if (validateStringInput(displayName)) {
await this.updatePrimaryDisplayName(displayName)
}
}, 500),
async updatePrimaryDisplayName(displayName) {
try {
const responseData = await savePrimaryDisplayName(displayName)
const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.DISPLAYNAME, displayName)
this.handleResponse({
displayName,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: 'Unable to update full name',
errorMessage: t('settings', 'Unable to update full name'),
error: e,
})
}
@ -106,10 +106,11 @@ export default {
if (status === 'ok') {
// Ensure that local state reflects server state
this.initialDisplayName = displayName
emit('settings:display-name:updated', displayName)
this.showCheckmarkIcon = true
setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
} else {
showError(t('settings', errorMessage))
showError(errorMessage)
this.logger.error(errorMessage, error)
this.showErrorIcon = true
setTimeout(() => { this.showErrorIcon = false }, 2000)

View file

@ -17,6 +17,7 @@
-
- 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>
@ -26,13 +27,17 @@
label-for="displayname"
:is-editable="displayNameChangeSupported"
:is-valid-section="isValidSection"
:handle-scope-change="savePrimaryDisplayNameScope"
:scope.sync="primaryDisplayName.scope" />
<template v-if="displayNameChangeSupported">
<DisplayName
:display-name.sync="primaryDisplayName.value"
:scope.sync="primaryDisplayName.scope" />
<VisibilityDropdown
:param-id="accountPropertyId"
:display-id="accountProperty"
:visibility.sync="visibility" />
</template>
<span v-else>
@ -46,13 +51,14 @@ import { loadState } from '@nextcloud/initial-state'
import DisplayName from './DisplayName'
import HeaderBar from '../shared/HeaderBar'
import VisibilityDropdown from '../shared/VisibilityDropdown'
import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryDisplayNameScope } from '../../../service/PersonalInfo/DisplayNameService'
import { validateDisplayName } from '../../../utils/validate'
import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
import { validateStringInput } from '../../../utils/validate'
const { displayNames: { primaryDisplayName } } = loadState('settings', 'personalInfoParameters', {})
const { displayNameMap: { primaryDisplayName } } = loadState('settings', 'personalInfoParameters', {})
const { displayNameChangeSupported } = loadState('settings', 'accountParameters', {})
const { profileConfig: { displayname: { visibility } } } = loadState('settings', 'profileParameters', {})
export default {
name: 'DisplayNameSection',
@ -60,20 +66,22 @@ export default {
components: {
DisplayName,
HeaderBar,
VisibilityDropdown,
},
data() {
return {
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.DISPLAYNAME,
accountPropertyId: ACCOUNT_PROPERTY_ENUM.DISPLAYNAME,
displayNameChangeSupported,
primaryDisplayName,
savePrimaryDisplayNameScope,
visibility,
}
},
computed: {
isValidSection() {
return validateDisplayName(this.primaryDisplayName.value)
return validateStringInput(this.primaryDisplayName.value)
},
},
}

View file

@ -17,22 +17,21 @@
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div>
<div class="email">
<input
id="email"
:id="inputId"
ref="email"
type="email"
:name="inputName"
:placeholder="inputPlaceholder"
:value="email"
autocapitalize="none"
autocomplete="on"
autocorrect="off"
required
@input="onEmailChange">
<div class="email__actions-container">
@ -47,7 +46,7 @@
:additional="true"
:additional-value="email"
:disabled="federationDisabled"
:handle-scope-change="saveAdditionalEmailScope"
:handle-additional-scope-change="saveAdditionalEmailScope"
:scope.sync="localScope"
@update:scope="onScopeChange" />
</template>
@ -185,11 +184,11 @@ export default {
return !this.initialEmail
},
inputName() {
inputId() {
if (this.primary) {
return 'email'
}
return 'additionalEmail[]'
return `email-${this.index}`
},
inputPlaceholder() {
@ -253,12 +252,12 @@ export default {
} catch (e) {
if (email === '') {
this.handleResponse({
errorMessage: 'Unable to delete primary email address',
errorMessage: t('settings', 'Unable to delete primary email address'),
error: e,
})
} else {
this.handleResponse({
errorMessage: 'Unable to update primary email address',
errorMessage: t('settings', 'Unable to update primary email address'),
error: e,
})
}
@ -274,7 +273,7 @@ export default {
})
} catch (e) {
this.handleResponse({
errorMessage: 'Unable to add additional email address',
errorMessage: t('settings', 'Unable to add additional email address'),
error: e,
})
}
@ -305,7 +304,7 @@ export default {
})
} catch (e) {
this.handleResponse({
errorMessage: 'Unable to update additional email address',
errorMessage: t('settings', 'Unable to update additional email address'),
error: e,
})
}
@ -317,7 +316,7 @@ export default {
this.handleDeleteAdditionalEmail(responseData.ocs?.meta?.status)
} catch (e) {
this.handleResponse({
errorMessage: 'Unable to delete additional email address',
errorMessage: t('settings', 'Unable to delete additional email address'),
error: e,
})
}
@ -328,7 +327,7 @@ export default {
this.$emit('delete-additional-email')
} else {
this.handleResponse({
errorMessage: 'Unable to delete additional email address',
errorMessage: t('settings', 'Unable to delete additional email address'),
})
}
},
@ -344,7 +343,7 @@ export default {
this.showCheckmarkIcon = true
setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
} else {
showError(t('settings', errorMessage))
showError(errorMessage)
this.logger.error(errorMessage, error)
this.showErrorIcon = true
setTimeout(() => { this.showErrorIcon = false }, 2000)

View file

@ -17,6 +17,7 @@
-
- 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>
@ -39,20 +40,30 @@
:active-notification-email.sync="notificationEmail"
@update:email="onUpdateEmail"
@update:notification-email="onUpdateNotificationEmail" />
<VisibilityDropdown
:param-id="accountPropertyId"
:display-id="accountProperty"
:visibility.sync="visibility" />
</template>
<span v-else>
{{ primaryEmail.value || t('settings', 'No email address set') }}
</span>
<Email v-for="(additionalEmail, index) in additionalEmails"
:key="index"
:index="index"
:scope.sync="additionalEmail.scope"
:email.sync="additionalEmail.value"
:local-verification-state="parseInt(additionalEmail.locallyVerified, 10)"
:active-notification-email.sync="notificationEmail"
@update:email="onUpdateEmail"
@update:notification-email="onUpdateNotificationEmail"
@delete-additional-email="onDeleteAdditionalEmail(index)" />
<template v-if="additionalEmails.length">
<em class="additional-emails-label">{{ t('settings', 'Additional emails') }}</em>
<Email v-for="(additionalEmail, index) in additionalEmails"
:key="index"
:index="index"
:scope.sync="additionalEmail.scope"
:email.sync="additionalEmail.value"
:local-verification-state="parseInt(additionalEmail.locallyVerified, 10)"
:active-notification-email.sync="notificationEmail"
@update:email="onUpdateEmail"
@update:notification-email="onUpdateNotificationEmail"
@delete-additional-email="onDeleteAdditionalEmail(index)" />
</template>
</section>
</template>
@ -62,13 +73,15 @@ import { showError } from '@nextcloud/dialogs'
import Email from './Email'
import HeaderBar from '../shared/HeaderBar'
import VisibilityDropdown from '../shared/VisibilityDropdown'
import { ACCOUNT_PROPERTY_READABLE_ENUM, DEFAULT_ADDITIONAL_EMAIL_SCOPE } from '../../../constants/AccountPropertyConstants'
import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM, DEFAULT_ADDITIONAL_EMAIL_SCOPE } from '../../../constants/AccountPropertyConstants'
import { savePrimaryEmail, savePrimaryEmailScope, removeAdditionalEmail } from '../../../service/PersonalInfo/EmailService'
import { validateEmail } from '../../../utils/validate'
const { emails: { additionalEmails, primaryEmail, notificationEmail } } = loadState('settings', 'personalInfoParameters', {})
const { emailMap: { additionalEmails, primaryEmail, notificationEmail } } = loadState('settings', 'personalInfoParameters', {})
const { displayNameChangeSupported } = loadState('settings', 'accountParameters', {})
const { profileConfig: { email: { visibility } } } = loadState('settings', 'profileParameters', {})
export default {
name: 'EmailSection',
@ -76,16 +89,19 @@ export default {
components: {
HeaderBar,
Email,
VisibilityDropdown,
},
data() {
return {
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL,
accountPropertyId: ACCOUNT_PROPERTY_ENUM.EMAIL,
additionalEmails,
displayNameChangeSupported,
primaryEmail,
savePrimaryEmailScope,
notificationEmail,
visibility,
}
},
@ -141,7 +157,11 @@ export default {
const responseData = await savePrimaryEmail(this.primaryEmailValue)
this.handleResponse(responseData.ocs?.meta?.status)
} catch (e) {
this.handleResponse('error', 'Unable to update primary email address', e)
this.handleResponse(
'error',
t('settings', 'Unable to update primary email address'),
e
)
}
},
@ -150,7 +170,11 @@ export default {
const responseData = await removeAdditionalEmail(this.firstAdditionalEmail)
this.handleDeleteFirstAdditionalEmail(responseData.ocs?.meta?.status)
} catch (e) {
this.handleResponse('error', 'Unable to delete additional email address', e)
this.handleResponse(
'error',
t('settings', 'Unable to delete additional email address'),
e
)
}
},
@ -158,13 +182,17 @@ export default {
if (status === 'ok') {
this.$delete(this.additionalEmails, 0)
} else {
this.handleResponse('error', 'Unable to delete additional email address', {})
this.handleResponse(
'error',
t('settings', 'Unable to delete additional email address'),
{}
)
}
},
handleResponse(status, errorMessage, error) {
if (status !== 'ok') {
showError(t('settings', errorMessage))
showError(errorMessage)
this.logger.error(errorMessage, error)
}
},
@ -179,5 +207,10 @@ section {
&::v-deep button:disabled {
cursor: default;
}
.additional-emails-label {
display: block;
margin-top: 16px;
}
}
</style>

View file

@ -0,0 +1,175 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="headline">
<input
id="headline"
type="text"
:placeholder="t('settings', 'Your headline')"
:value="headline"
autocapitalize="none"
autocomplete="on"
autocorrect="off"
@input="onHeadlineChange">
<div class="headline__actions-container">
<transition name="fade">
<span v-if="showCheckmarkIcon" class="icon-checkmark" />
<span v-else-if="showErrorIcon" class="icon-error" />
</transition>
</div>
</div>
</template>
<script>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import debounce from 'debounce'
import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
export default {
name: 'Headline',
props: {
headline: {
type: String,
required: true,
},
scope: {
type: String,
required: true,
},
},
data() {
return {
initialHeadline: this.headline,
localScope: this.scope,
showCheckmarkIcon: false,
showErrorIcon: false,
}
},
methods: {
onHeadlineChange(e) {
this.$emit('update:headline', e.target.value)
this.debounceHeadlineChange(e.target.value.trim())
},
debounceHeadlineChange: debounce(async function(headline) {
await this.updatePrimaryHeadline(headline)
}, 500),
async updatePrimaryHeadline(headline) {
try {
const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.HEADLINE, headline)
this.handleResponse({
headline,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update headline'),
error: e,
})
}
},
handleResponse({ headline, status, errorMessage, error }) {
if (status === 'ok') {
// Ensure that local state reflects server state
this.initialHeadline = headline
emit('settings:headline:updated', headline)
this.showCheckmarkIcon = true
setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
} else {
showError(errorMessage)
this.logger.error(errorMessage, error)
this.showErrorIcon = true
setTimeout(() => { this.showErrorIcon = false }, 2000)
}
},
onScopeChange(scope) {
this.$emit('update:scope', scope)
},
},
}
</script>
<style lang="scss" scoped>
.headline {
display: grid;
align-items: center;
input {
grid-area: 1 / 1;
width: 100%;
height: 34px;
margin: 3px 3px 3px 0;
padding: 7px 6px;
color: var(--color-main-text);
border: 1px solid var(--color-border-dark);
border-radius: var(--border-radius);
background-color: var(--color-main-background);
font-family: var(--font-face);
cursor: text;
}
.headline__actions-container {
grid-area: 1 / 1;
justify-self: flex-end;
height: 30px;
display: flex;
gap: 0 2px;
margin-right: 5px;
.icon-checkmark,
.icon-error {
height: 30px !important;
min-height: 30px !important;
width: 30px !important;
min-width: 30px !important;
top: 0;
right: 0;
float: none;
}
}
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
.fade-enter-active {
transition: opacity 200ms ease-out;
}
.fade-leave-active {
transition: opacity 300ms ease-out;
}
</style>

View file

@ -0,0 +1,81 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<section>
<HeaderBar
:account-property="accountProperty"
label-for="headline"
:scope.sync="primaryHeadline.scope" />
<Headline
:headline.sync="primaryHeadline.value"
:scope.sync="primaryHeadline.scope" />
<VisibilityDropdown
:param-id="accountPropertyId"
:display-id="accountProperty"
:visibility.sync="visibility" />
</section>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import Headline from './Headline'
import HeaderBar from '../shared/HeaderBar'
import VisibilityDropdown from '../shared/VisibilityDropdown'
import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
const { headlineMap: { primaryHeadline } } = loadState('settings', 'personalInfoParameters', {})
const { profileConfig: { headline: { visibility } } } = loadState('settings', 'profileParameters', {})
export default {
name: 'HeadlineSection',
components: {
Headline,
HeaderBar,
VisibilityDropdown,
},
data() {
return {
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.HEADLINE,
accountPropertyId: ACCOUNT_PROPERTY_ENUM.HEADLINE,
primaryHeadline,
visibility,
}
},
}
</script>
<style lang="scss" scoped>
section {
padding: 10px 10px;
&::v-deep button:disabled {
cursor: default;
}
}
</style>

View file

@ -17,16 +17,14 @@
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="language">
<select
id="language"
ref="language"
name="language"
:placeholder="t('settings', 'Language')"
required
@change="onLanguageChange">
<option v-for="commonLanguage in commonLanguages"
:key="commonLanguage.code"
@ -57,7 +55,8 @@
<script>
import { showError } from '@nextcloud/dialogs'
import { saveLanguage } from '../../../service/PersonalInfo/LanguageService'
import { ACCOUNT_SETTING_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
import { validateLanguage } from '../../../utils/validate'
export default {
@ -105,7 +104,7 @@ export default {
async updateLanguage(language) {
try {
const responseData = await saveLanguage(language.code)
const responseData = await savePrimaryAccountProperty(ACCOUNT_SETTING_PROPERTY_ENUM.LANGUAGE, language.code)
this.handleResponse({
language,
status: responseData.ocs?.meta?.status,
@ -113,7 +112,7 @@ export default {
this.reloadPage()
} catch (e) {
this.handleResponse({
errorMessage: 'Unable to update language',
errorMessage: t('settings', 'Unable to update language'),
error: e,
})
}
@ -131,7 +130,7 @@ export default {
// Ensure that local state reflects server state
this.initialLanguage = language
} else {
showError(t('settings', errorMessage))
showError(errorMessage)
this.logger.error(errorMessage, error)
}
},

View file

@ -17,14 +17,14 @@
-
- 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>
<section>
<HeaderBar
:account-property="accountProperty"
label-for="language"
:is-valid-section="isValidSection" />
label-for="language" />
<template v-if="isEditable">
<Language
@ -45,10 +45,9 @@ import { loadState } from '@nextcloud/initial-state'
import Language from './Language'
import HeaderBar from '../shared/HeaderBar'
import { SETTING_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
import { validateLanguage } from '../../../utils/validate'
import { ACCOUNT_SETTING_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
const { languages: { activeLanguage, commonLanguages, otherLanguages } } = loadState('settings', 'personalInfoParameters', {})
const { languageMap: { activeLanguage, commonLanguages, otherLanguages } } = loadState('settings', 'personalInfoParameters', {})
export default {
name: 'LanguageSection',
@ -60,7 +59,7 @@ export default {
data() {
return {
accountProperty: SETTING_PROPERTY_READABLE_ENUM.LANGUAGE,
accountProperty: ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.LANGUAGE,
commonLanguages,
otherLanguages,
language: activeLanguage,
@ -71,10 +70,6 @@ export default {
isEditable() {
return Boolean(this.language)
},
isValidSection() {
return validateLanguage(this.language)
},
},
}
</script>

View file

@ -0,0 +1,175 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="organisation">
<input
id="organisation"
type="text"
:placeholder="t('settings', 'Your organisation')"
:value="organisation"
autocapitalize="none"
autocomplete="on"
autocorrect="off"
@input="onOrganisationChange">
<div class="organisation__actions-container">
<transition name="fade">
<span v-if="showCheckmarkIcon" class="icon-checkmark" />
<span v-else-if="showErrorIcon" class="icon-error" />
</transition>
</div>
</div>
</template>
<script>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import debounce from 'debounce'
import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
export default {
name: 'Organisation',
props: {
organisation: {
type: String,
required: true,
},
scope: {
type: String,
required: true,
},
},
data() {
return {
initialOrganisation: this.organisation,
localScope: this.scope,
showCheckmarkIcon: false,
showErrorIcon: false,
}
},
methods: {
onOrganisationChange(e) {
this.$emit('update:organisation', e.target.value)
this.debounceOrganisationChange(e.target.value.trim())
},
debounceOrganisationChange: debounce(async function(organisation) {
await this.updatePrimaryOrganisation(organisation)
}, 500),
async updatePrimaryOrganisation(organisation) {
try {
const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.ORGANISATION, organisation)
this.handleResponse({
organisation,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update organisation'),
error: e,
})
}
},
handleResponse({ organisation, status, errorMessage, error }) {
if (status === 'ok') {
// Ensure that local state reflects server state
this.initialOrganisation = organisation
emit('settings:organisation:updated', organisation)
this.showCheckmarkIcon = true
setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
} else {
showError(errorMessage)
this.logger.error(errorMessage, error)
this.showErrorIcon = true
setTimeout(() => { this.showErrorIcon = false }, 2000)
}
},
onScopeChange(scope) {
this.$emit('update:scope', scope)
},
},
}
</script>
<style lang="scss" scoped>
.organisation {
display: grid;
align-items: center;
input {
grid-area: 1 / 1;
width: 100%;
height: 34px;
margin: 3px 3px 3px 0;
padding: 7px 6px;
color: var(--color-main-text);
border: 1px solid var(--color-border-dark);
border-radius: var(--border-radius);
background-color: var(--color-main-background);
font-family: var(--font-face);
cursor: text;
}
.organisation__actions-container {
grid-area: 1 / 1;
justify-self: flex-end;
height: 30px;
display: flex;
gap: 0 2px;
margin-right: 5px;
.icon-checkmark,
.icon-error {
height: 30px !important;
min-height: 30px !important;
width: 30px !important;
min-width: 30px !important;
top: 0;
right: 0;
float: none;
}
}
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
.fade-enter-active {
transition: opacity 200ms ease-out;
}
.fade-leave-active {
transition: opacity 300ms ease-out;
}
</style>

View file

@ -0,0 +1,81 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<section>
<HeaderBar
:account-property="accountProperty"
label-for="organisation"
:scope.sync="primaryOrganisation.scope" />
<Organisation
:organisation.sync="primaryOrganisation.value"
:scope.sync="primaryOrganisation.scope" />
<VisibilityDropdown
:param-id="accountPropertyId"
:display-id="accountProperty"
:visibility.sync="visibility" />
</section>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import Organisation from './Organisation'
import HeaderBar from '../shared/HeaderBar'
import VisibilityDropdown from '../shared/VisibilityDropdown'
import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
const { organisationMap: { primaryOrganisation } } = loadState('settings', 'personalInfoParameters', {})
const { profileConfig: { organisation: { visibility } } } = loadState('settings', 'profileParameters', {})
export default {
name: 'OrganisationSection',
components: {
Organisation,
HeaderBar,
VisibilityDropdown,
},
data() {
return {
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.ORGANISATION,
accountPropertyId: ACCOUNT_PROPERTY_ENUM.ORGANISATION,
primaryOrganisation,
visibility,
}
},
}
</script>
<style lang="scss" scoped>
section {
padding: 10px 10px;
&::v-deep button:disabled {
cursor: default;
}
}
</style>

View file

@ -0,0 +1,101 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="checkbox-container">
<input
id="enable-profile"
class="checkbox"
type="checkbox"
:checked="profileEnabled"
@change="onEnableProfileChange">
<label for="enable-profile">
{{ t('settings', 'Enable Profile') }}
</label>
</div>
</template>
<script>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
import { validateBoolean } from '../../../utils/validate'
import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
export default {
name: 'ProfileCheckbox',
props: {
profileEnabled: {
type: Boolean,
required: true,
},
},
data() {
return {
initialProfileEnabled: this.profileEnabled,
}
},
methods: {
async onEnableProfileChange(e) {
const isEnabled = e.target.checked
this.$emit('update:profile-enabled', isEnabled)
if (validateBoolean(isEnabled)) {
await this.updateEnableProfile(isEnabled)
}
},
async updateEnableProfile(isEnabled) {
try {
const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.PROFILE_ENABLED, isEnabled)
this.handleResponse({
isEnabled,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update profile enabled state'),
error: e,
})
}
},
handleResponse({ isEnabled, status, errorMessage, error }) {
if (status === 'ok') {
// Ensure that local state reflects server state
this.initialProfileEnabled = isEnabled
emit('settings:profile-enabled:updated', isEnabled)
} else {
showError(errorMessage)
this.logger.error(errorMessage, error)
}
},
},
}
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,192 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<a
class="preview-card"
:class="{ disabled }"
:href="profilePageLink">
<Avatar
class="preview-card__avatar"
:user="userId"
:size="48"
:show-user-status="true"
:show-user-status-compact="false"
:disable-menu="true"
:disable-tooltip="true"
@click.native.prevent.stop="openStatusModal" />
<div class="preview-card__header">
<span>{{ displayName }}</span>
</div>
<div class="preview-card__footer">
<span>{{ organisation }}</span>
</div>
</a>
</template>
<script>
import { getCurrentUser } from '@nextcloud/auth'
import { generateUrl } from '@nextcloud/router'
import Avatar from '@nextcloud/vue/dist/Components/Avatar'
export default {
name: 'ProfilePreviewCard',
components: {
Avatar,
},
props: {
organisation: {
type: String,
required: true,
},
displayName: {
type: String,
required: true,
},
profileEnabled: {
type: Boolean,
required: true,
},
userId: {
type: String,
required: true,
},
},
data() {
return {
}
},
computed: {
disabled() {
return !this.profileEnabled
},
profilePageLink() {
if (this.profileEnabled) {
return generateUrl('/u/{userId}', { userId: getCurrentUser().uid })
}
// Since an anchor element is used rather than a button for better UX,
// this hack removes href if the profile is disabled so that disabling pointer-events is not needed to prevent a click from opening a page
// and to allow the hover event (which disabling pointer-events wouldn't allow) for styling
return null
},
},
methods: {
},
}
</script>
<style lang="scss" scoped>
.preview-card {
display: flex;
flex-direction: column;
position: relative;
width: 290px;
height: 116px;
margin: 14px auto;
border-radius: var(--border-radius-large);
background-color: var(--color-main-background);
font-weight: bold;
box-shadow: 0 2px 9px var(--color-box-shadow);
&:hover {
box-shadow: 0 2px 12px var(--color-box-shadow);
}
&.disabled {
filter: grayscale(1);
opacity: 0.5;
cursor: default;
box-shadow: 0 0 3px var(--color-box-shadow);
& *,
&::v-deep * {
cursor: default;
}
}
&__avatar {
// Override Avatar component position to fix positioning on rerender
position: absolute !important;
top: 40px;
left: 18px;
z-index: 1;
&:not(.avatardiv--unknown) {
box-shadow: 0 0 0 3px var(--color-main-background) !important;
}
}
&__header {
position: relative !important;
width: auto !important;
height: 70px !important;
border-radius: var(--border-radius-large) var(--border-radius-large) 0 0 !important;
span {
position: absolute;
bottom: 0;
left: 78px;
color: var(--color-primary-text);
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
}
}
&__footer {
position: relative;
width: auto;
height: 46px;
span {
position: absolute;
top: 0;
left: 78px;
color: var(--color-text-maxcontrast);
font-size: 14px;
font-weight: normal;
margin-top: 4px;
line-height: 1.3;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@supports (-webkit-line-clamp: 2) {
overflow: hidden;
white-space: initial;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
}
}
</style>

View file

@ -0,0 +1,105 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<section>
<HeaderBar
:account-property="accountProperty" />
<ProfileCheckbox
:profile-enabled.sync="profileEnabled" />
<ProfilePreviewCard
:organisation="organisation"
:display-name="displayName"
:profile-enabled="profileEnabled"
:user-id="userId" />
</section>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import HeaderBar from '../shared/HeaderBar'
import ProfileCheckbox from './ProfileCheckbox'
import ProfilePreviewCard from './ProfilePreviewCard'
import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
const {
organisationMap: { primaryOrganisation: { value: organisation } },
displayNameMap: { primaryDisplayName: { value: displayName } },
profileEnabled,
userId,
} = loadState('settings', 'personalInfoParameters', {})
export default {
name: 'ProfileSection',
components: {
HeaderBar,
ProfileCheckbox,
ProfilePreviewCard,
},
data() {
return {
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED,
organisation,
displayName,
profileEnabled,
userId,
}
},
mounted() {
subscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
subscribe('settings:organisation:updated', this.handleOrganisationUpdate)
},
beforeDestroy() {
unsubscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
unsubscribe('settings:organisation:updated', this.handleOrganisationUpdate)
},
methods: {
handleDisplayNameUpdate(displayName) {
this.displayName = displayName
},
handleOrganisationUpdate(organisation) {
this.organisation = organisation
},
},
}
</script>
<style lang="scss" scoped>
section {
padding: 10px 10px;
&::v-deep button:disabled {
cursor: default;
}
}
</style>

View file

@ -0,0 +1,117 @@
<!--
- @copyright 2021 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<section>
<HeaderBar
:account-property="heading" />
<VisibilityDropdown v-for="parameter in visibilityArray"
:key="parameter.id"
:param-id="parameter.id"
:display-id="parameter.displayId"
:show-display-id="true"
:visibility.sync="parameter.visibility" />
<em :class="{ disabled }">{{ t('settings', 'The more restrictive setting of either visibility or scope is respected on your Profile — For example, when visibility is set to "Show to everyone" and scope is set to "Private", "Private" will be respected') }}</em>
</section>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import HeaderBar from '../shared/HeaderBar'
import VisibilityDropdown from '../shared/VisibilityDropdown'
import { ACCOUNT_PROPERTY_ENUM, PROFILE_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
const { profileConfig } = loadState('settings', 'profileParameters', {})
const { profileEnabled } = loadState('settings', 'personalInfoParameters', false)
export default {
name: 'ProfileVisibilitySection',
components: {
HeaderBar,
VisibilityDropdown,
},
data() {
return {
heading: PROFILE_READABLE_ENUM.PROFILE_VISIBILITY,
profileEnabled,
visibilityArray: Object.entries(profileConfig)
// Filter for profile parameters registered by apps in this section as visibility controls for the rest (account properties) are handled in their respective property sections
.filter(([paramId, { displayId, visibility }]) => !Object.values(ACCOUNT_PROPERTY_ENUM).includes(paramId))
.map(([paramId, { displayId, visibility }]) => ({ id: paramId, displayId, visibility })),
}
},
computed: {
disabled() {
return !this.profileEnabled
},
},
mounted() {
subscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
},
beforeDestroy() {
unsubscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
},
methods: {
handleProfileEnabledUpdate(profileEnabled) {
this.profileEnabled = profileEnabled
},
},
}
</script>
<style lang="scss" scoped>
section {
padding: 10px 10px;
em {
display: block;
margin-top: 16px;
&.disabled {
filter: grayscale(1);
opacity: 0.5;
cursor: default;
pointer-events: none;
& *,
&::v-deep * {
cursor: default;
pointer-events: none;
}
}
}
&::v-deep button:disabled {
cursor: default;
}
}
</style>

View file

@ -0,0 +1,175 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="role">
<input
id="role"
type="text"
:placeholder="t('settings', 'Your role')"
:value="role"
autocapitalize="none"
autocomplete="on"
autocorrect="off"
@input="onRoleChange">
<div class="role__actions-container">
<transition name="fade">
<span v-if="showCheckmarkIcon" class="icon-checkmark" />
<span v-else-if="showErrorIcon" class="icon-error" />
</transition>
</div>
</div>
</template>
<script>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import debounce from 'debounce'
import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
export default {
name: 'Role',
props: {
role: {
type: String,
required: true,
},
scope: {
type: String,
required: true,
},
},
data() {
return {
initialRole: this.role,
localScope: this.scope,
showCheckmarkIcon: false,
showErrorIcon: false,
}
},
methods: {
onRoleChange(e) {
this.$emit('update:role', e.target.value)
this.debounceRoleChange(e.target.value.trim())
},
debounceRoleChange: debounce(async function(role) {
await this.updatePrimaryRole(role)
}, 500),
async updatePrimaryRole(role) {
try {
const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.ROLE, role)
this.handleResponse({
role,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update role'),
error: e,
})
}
},
handleResponse({ role, status, errorMessage, error }) {
if (status === 'ok') {
// Ensure that local state reflects server state
this.initialRole = role
emit('settings:role:updated', role)
this.showCheckmarkIcon = true
setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
} else {
showError(errorMessage)
this.logger.error(errorMessage, error)
this.showErrorIcon = true
setTimeout(() => { this.showErrorIcon = false }, 2000)
}
},
onScopeChange(scope) {
this.$emit('update:scope', scope)
},
},
}
</script>
<style lang="scss" scoped>
.role {
display: grid;
align-items: center;
input {
grid-area: 1 / 1;
width: 100%;
height: 34px;
margin: 3px 3px 3px 0;
padding: 7px 6px;
color: var(--color-main-text);
border: 1px solid var(--color-border-dark);
border-radius: var(--border-radius);
background-color: var(--color-main-background);
font-family: var(--font-face);
cursor: text;
}
.role__actions-container {
grid-area: 1 / 1;
justify-self: flex-end;
height: 30px;
display: flex;
gap: 0 2px;
margin-right: 5px;
.icon-checkmark,
.icon-error {
height: 30px !important;
min-height: 30px !important;
width: 30px !important;
min-width: 30px !important;
top: 0;
right: 0;
float: none;
}
}
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
.fade-enter-active {
transition: opacity 200ms ease-out;
}
.fade-leave-active {
transition: opacity 300ms ease-out;
}
</style>

View file

@ -0,0 +1,81 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<section>
<HeaderBar
:account-property="accountProperty"
label-for="role"
:scope.sync="primaryRole.scope" />
<Role
:role.sync="primaryRole.value"
:scope.sync="primaryRole.scope" />
<VisibilityDropdown
:param-id="accountPropertyId"
:display-id="accountProperty"
:visibility.sync="visibility" />
</section>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import Role from './Role'
import HeaderBar from '../shared/HeaderBar'
import VisibilityDropdown from '../shared/VisibilityDropdown'
import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
const { roleMap: { primaryRole } } = loadState('settings', 'personalInfoParameters', {})
const { profileConfig: { role: { visibility } } } = loadState('settings', 'profileParameters', {})
export default {
name: 'RoleSection',
components: {
Role,
HeaderBar,
VisibilityDropdown,
},
data() {
return {
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.ROLE,
accountPropertyId: ACCOUNT_PROPERTY_ENUM.ROLE,
primaryRole,
visibility,
}
},
}
</script>
<style lang="scss" scoped>
section {
padding: 10px 10px;
&::v-deep button:disabled {
cursor: default;
}
}
</style>

View file

@ -17,6 +17,7 @@
-
- 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>

View file

@ -17,6 +17,7 @@
-
- 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>
@ -25,27 +26,28 @@
:aria-label="ariaLabel"
:default-icon="scopeIcon"
:disabled="disabled">
<ActionButton v-for="federationScope in federationScopes"
<FederationControlAction v-for="federationScope in federationScopes"
:key="federationScope.name"
:aria-label="federationScope.tooltip"
class="federation-actions__btn"
:class="{ 'federation-actions__btn--active': scope === federationScope.name }"
:close-after-click="true"
:icon="federationScope.iconClass"
:title="federationScope.displayName"
@click.stop.prevent="changeScope(federationScope.name)">
{{ federationScope.tooltip }}
</ActionButton>
:active-scope="scope"
:display-name="federationScope.displayName"
:handle-scope-change="changeScope"
:icon-class="federationScope.iconClass"
:is-supported-scope="supportedScopes.includes(federationScope.name)"
:name="federationScope.name"
:tooltip-disabled="federationScope.tooltipDisabled"
:tooltip="federationScope.tooltip" />
</Actions>
</template>
<script>
import Actions from '@nextcloud/vue/dist/Components/Actions'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import { loadState } from '@nextcloud/initial-state'
import { showError } from '@nextcloud/dialogs'
import { ACCOUNT_PROPERTY_READABLE_ENUM, PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM, SCOPE_ENUM, SCOPE_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import FederationControlAction from './FederationControlAction'
import { ACCOUNT_PROPERTY_READABLE_ENUM, PROPERTY_READABLE_KEYS_ENUM, PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM, SCOPE_ENUM, SCOPE_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryAccountPropertyScope } from '../../../service/PersonalInfo/PersonalInfoService'
const { lookupServerUploadEnabled } = loadState('settings', 'accountParameters', {})
@ -54,7 +56,7 @@ export default {
components: {
Actions,
ActionButton,
FederationControlAction,
},
props: {
@ -75,9 +77,9 @@ export default {
type: Boolean,
default: false,
},
handleScopeChange: {
handleAdditionalScopeChange: {
type: Function,
required: true,
default: null,
},
scope: {
type: String,
@ -94,17 +96,17 @@ export default {
computed: {
ariaLabel() {
return t('settings', 'Change privacy level of {accountProperty}', { accountProperty: this.accountPropertyLowerCase })
},
federationScopes() {
return Object.values(SCOPE_PROPERTY_ENUM).filter(({ name }) => this.supportedScopes.includes(name))
return t('settings', 'Change scope level of {accountProperty}', { accountProperty: this.accountPropertyLowerCase })
},
scopeIcon() {
return SCOPE_PROPERTY_ENUM[this.scope].iconClass
},
federationScopes() {
return Object.values(SCOPE_PROPERTY_ENUM)
},
supportedScopes() {
if (lookupServerUploadEnabled) {
return [
@ -131,7 +133,7 @@ export default {
async updatePrimaryScope(scope) {
try {
const responseData = await this.handleScopeChange(scope)
const responseData = await savePrimaryAccountPropertyScope(PROPERTY_READABLE_KEYS_ENUM[this.accountProperty], scope)
this.handleResponse({
scope,
status: responseData.ocs?.meta?.status,
@ -146,7 +148,7 @@ export default {
async updateAdditionalScope(scope) {
try {
const responseData = await this.handleScopeChange(this.additionalValue, scope)
const responseData = await this.handleAdditionalScopeChange(this.additionalValue, scope)
this.handleResponse({
scope,
status: responseData.ocs?.meta?.status,
@ -192,19 +194,4 @@ export default {
min-width: 30px !important;
}
}
.federation-actions__btn {
&::v-deep p {
width: 150px !important;
padding: 8px 0 !important;
color: var(--color-main-text) !important;
font-size: 12.8px !important;
line-height: 1.5em !important;
}
}
.federation-actions__btn--active {
background-color: var(--color-primary-light) !important;
box-shadow: inset 2px 0 var(--color-primary) !important;
}
</style>

View file

@ -0,0 +1,105 @@
<!--
- @copyright 2021 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<ActionButton
:aria-label="isSupportedScope ? tooltip : tooltipDisabled"
class="federation-actions__btn"
:class="{ 'federation-actions__btn--active': activeScope === name }"
:close-after-click="true"
:disabled="!isSupportedScope"
:icon="iconClass"
:title="displayName"
@click.stop.prevent="updateScope">
{{ isSupportedScope ? tooltip : tooltipDisabled }}
</ActionButton>
</template>
<script>
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
export default {
name: 'FederationControlAction',
components: {
ActionButton,
},
props: {
activeScope: {
type: String,
required: true,
},
displayName: {
type: String,
required: true,
},
handleScopeChange: {
type: Function,
default: () => {},
},
iconClass: {
type: String,
required: true,
},
isSupportedScope: {
type: Boolean,
required: true,
},
name: {
type: String,
required: true,
},
tooltipDisabled: {
type: String,
default: '',
},
tooltip: {
type: String,
required: true,
},
},
methods: {
updateScope() {
this.handleScopeChange(this.name)
},
},
}
</script>
<style lang="scss" scoped>
.federation-actions__btn {
&::v-deep p {
width: 150px !important;
padding: 8px 0 !important;
color: var(--color-main-text) !important;
font-size: 12.8px !important;
line-height: 1.5em !important;
}
}
.federation-actions__btn--active {
background-color: var(--color-primary-light) !important;
box-shadow: inset 2px 0 var(--color-primary) !important;
}
</style>

View file

@ -17,21 +17,21 @@
-
- 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>
<h3
:class="{ 'setting-property': isSettingProperty }">
:class="{ 'setting-property': isSettingProperty, 'profile-property': isProfileProperty }">
<label :for="labelFor">
<!-- Already translated as required by prop validator -->
{{ accountProperty }}
</label>
<template v-if="scope && handleScopeChange">
<template v-if="scope">
<FederationControl
class="federation-control"
:account-property="accountProperty"
:handle-scope-change="handleScopeChange"
:scope.sync="localScope"
@update:scope="onScopeChange" />
</template>
@ -49,7 +49,7 @@
import AddButton from './AddButton'
import FederationControl from './FederationControl'
import { ACCOUNT_PROPERTY_READABLE_ENUM, SETTING_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
import { ACCOUNT_PROPERTY_READABLE_ENUM, ACCOUNT_SETTING_PROPERTY_READABLE_ENUM, PROFILE_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
export default {
name: 'HeaderBar',
@ -63,11 +63,7 @@ export default {
accountProperty: {
type: String,
required: true,
validator: (value) => Object.values(ACCOUNT_PROPERTY_READABLE_ENUM).includes(value) || Object.values(SETTING_PROPERTY_READABLE_ENUM).includes(value),
},
handleScopeChange: {
type: Function,
default: null,
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,
},
isEditable: {
type: Boolean,
@ -83,7 +79,7 @@ export default {
},
labelFor: {
type: String,
required: true,
default: '',
},
scope: {
type: String,
@ -98,8 +94,12 @@ export default {
},
computed: {
isProfileProperty() {
return this.accountProperty === ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED
},
isSettingProperty() {
return Object.values(SETTING_PROPERTY_READABLE_ENUM).includes(this.accountProperty)
return Object.values(ACCOUNT_SETTING_PROPERTY_READABLE_ENUM).includes(this.accountProperty)
},
},
@ -123,10 +123,14 @@ export default {
font-size: 16px;
color: var(--color-text-light);
&.setting-property {
&.profile-property {
height: 38px;
}
&.setting-property {
height: 32px;
}
label {
cursor: pointer;
}

View file

@ -0,0 +1,178 @@
<!--
- @copyright 2021 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div
class="visibility-container"
:class="{ disabled }">
<label :for="inputId">
{{ showDisplayId ? t('settings', '{displayId} visibility', { displayId }) : t('settings', 'Visibility on Profile') }}
</label>
<Multiselect
:id="inputId"
:options="visibilityOptions"
track-by="name"
label="label"
:value="visibilityObject"
@change="onVisibilityChange" />
</div>
</template>
<script>
import { showError } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import Multiselect from '@nextcloud/vue/dist/Components/Multiselect'
import { saveProfileParameterVisibility } from '../../../service/ProfileService'
import { validateStringInput } from '../../../utils/validate'
import { VISIBILITY_PROPERTY_ENUM } from '../../../constants/ProfileConstants'
const { profileConfig } = loadState('settings', 'profileParameters', {})
const { profileEnabled } = loadState('settings', 'personalInfoParameters', false)
export default {
name: 'VisibilityDropdown',
components: {
Multiselect,
},
props: {
paramId: {
type: String,
required: true,
},
displayId: {
type: String,
required: true,
},
showDisplayId: {
type: Boolean,
default: false,
},
},
data() {
return {
initialVisibility: profileConfig[this.paramId].visibility,
profileEnabled,
visibility: profileConfig[this.paramId].visibility,
}
},
computed: {
disabled() {
return !this.profileEnabled
},
inputId() {
return `profile-visibility-${this.paramId}`
},
visibilityObject() {
return VISIBILITY_PROPERTY_ENUM[this.visibility]
},
visibilityOptions() {
return Object.values(VISIBILITY_PROPERTY_ENUM)
},
},
mounted() {
subscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
},
beforeDestroy() {
unsubscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
},
methods: {
async onVisibilityChange(visibilityObject) {
// This check is needed as the argument is null when selecting the same option
if (visibilityObject !== null) {
const { name: visibility } = visibilityObject
this.visibility = visibility
if (validateStringInput(visibility)) {
await this.updateVisibility(visibility)
}
}
},
async updateVisibility(visibility) {
try {
const responseData = await saveProfileParameterVisibility(this.paramId, visibility)
this.handleResponse({
visibility,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update visibility of {displayId}', { displayId: this.displayId }),
error: e,
})
}
},
handleResponse({ visibility, status, errorMessage, error }) {
if (status === 'ok') {
// Ensure that local state reflects server state
this.initialVisibility = visibility
} else {
showError(errorMessage)
this.logger.error(errorMessage, error)
}
},
handleProfileEnabledUpdate(profileEnabled) {
this.profileEnabled = profileEnabled
},
},
}
</script>
<style lang="scss" scoped>
.visibility-container {
margin-top: 16px;
display: grid;
&.disabled {
filter: grayscale(1);
opacity: 0.5;
cursor: default;
pointer-events: none;
& *,
&::v-deep * {
cursor: default;
pointer-events: none;
}
}
label {
color: var(--color-text-lighter);
margin-bottom: 3px;
}
}
</style>

View file

@ -21,7 +21,7 @@
*/
/*
* SYNC to be kept in sync with lib/public/Accounts/IAccountManager.php
* SYNC to be kept in sync with `lib/public/Accounts/IAccountManager.php`
*/
import { translate as t } from '@nextcloud/l10n'
@ -30,11 +30,16 @@ import { translate as t } from '@nextcloud/l10n'
export const ACCOUNT_PROPERTY_ENUM = Object.freeze({
ADDRESS: 'address',
AVATAR: 'avatar',
BIOGRAPHY: 'biography',
DISPLAYNAME: 'displayname',
EMAIL: 'email',
EMAIL_COLLECTION: 'additional_mail',
EMAIL: 'email',
HEADLINE: 'headline',
NOTIFICATION_EMAIL: 'notify_email',
ORGANISATION: 'organisation',
PHONE: 'phone',
PROFILE_ENABLED: 'profile_enabled',
ROLE: 'role',
TWITTER: 'twitter',
WEBSITE: 'website',
})
@ -43,28 +48,59 @@ export const ACCOUNT_PROPERTY_ENUM = Object.freeze({
export const ACCOUNT_PROPERTY_READABLE_ENUM = Object.freeze({
ADDRESS: t('settings', 'Address'),
AVATAR: t('settings', 'Avatar'),
BIOGRAPHY: t('settings', 'About'),
DISPLAYNAME: t('settings', 'Full name'),
EMAIL: t('settings', 'Email'),
EMAIL_COLLECTION: t('settings', 'Additional email'),
EMAIL: t('settings', 'Email'),
HEADLINE: t('settings', 'Headline'),
ORGANISATION: t('settings', 'Organisation'),
PHONE: t('settings', 'Phone number'),
PROFILE_ENABLED: t('settings', 'Profile'),
ROLE: t('settings', 'Role'),
TWITTER: t('settings', 'Twitter'),
WEBSITE: t('settings', 'Website'),
})
/** Enum of setting properties */
export const SETTING_PROPERTY_ENUM = Object.freeze({
/** Enum of profile specific sections to human readable names */
export const PROFILE_READABLE_ENUM = Object.freeze({
PROFILE_VISIBILITY: t('settings', 'Profile Visibility'),
})
/** Enum of readable account properties to account property keys used by the server */
export const PROPERTY_READABLE_KEYS_ENUM = Object.freeze({
[ACCOUNT_PROPERTY_READABLE_ENUM.ADDRESS]: ACCOUNT_PROPERTY_ENUM.ADDRESS,
[ACCOUNT_PROPERTY_READABLE_ENUM.AVATAR]: ACCOUNT_PROPERTY_ENUM.AVATAR,
[ACCOUNT_PROPERTY_READABLE_ENUM.BIOGRAPHY]: ACCOUNT_PROPERTY_ENUM.BIOGRAPHY,
[ACCOUNT_PROPERTY_READABLE_ENUM.DISPLAYNAME]: ACCOUNT_PROPERTY_ENUM.DISPLAYNAME,
[ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL_COLLECTION]: ACCOUNT_PROPERTY_ENUM.EMAIL_COLLECTION,
[ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL]: ACCOUNT_PROPERTY_ENUM.EMAIL,
[ACCOUNT_PROPERTY_READABLE_ENUM.HEADLINE]: ACCOUNT_PROPERTY_ENUM.HEADLINE,
[ACCOUNT_PROPERTY_READABLE_ENUM.ORGANISATION]: ACCOUNT_PROPERTY_ENUM.ORGANISATION,
[ACCOUNT_PROPERTY_READABLE_ENUM.PHONE]: ACCOUNT_PROPERTY_ENUM.PHONE,
[ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED]: ACCOUNT_PROPERTY_ENUM.PROFILE_ENABLED,
[ACCOUNT_PROPERTY_READABLE_ENUM.ROLE]: ACCOUNT_PROPERTY_ENUM.ROLE,
[ACCOUNT_PROPERTY_READABLE_ENUM.TWITTER]: ACCOUNT_PROPERTY_ENUM.TWITTER,
[ACCOUNT_PROPERTY_READABLE_ENUM.WEBSITE]: ACCOUNT_PROPERTY_ENUM.WEBSITE,
})
/**
* Enum of account setting properties
*
* *Account setting properties unlike account properties do not support scopes*
*/
export const ACCOUNT_SETTING_PROPERTY_ENUM = Object.freeze({
LANGUAGE: 'language',
})
/** Enum of setting properties to human readable setting properties */
export const SETTING_PROPERTY_READABLE_ENUM = Object.freeze({
LANGUAGE: 'Language',
/** Enum of account setting properties to human readable setting properties */
export const ACCOUNT_SETTING_PROPERTY_READABLE_ENUM = Object.freeze({
LANGUAGE: t('settings', 'Language'),
})
/** Enum of scopes */
export const SCOPE_ENUM = Object.freeze({
LOCAL: 'v2-local',
PRIVATE: 'v2-private',
LOCAL: 'v2-local',
FEDERATED: 'v2-federated',
PUBLISHED: 'v2-published',
})
@ -73,10 +109,15 @@ export const SCOPE_ENUM = Object.freeze({
export const PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM = Object.freeze({
[ACCOUNT_PROPERTY_READABLE_ENUM.ADDRESS]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.AVATAR]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.BIOGRAPHY]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.DISPLAYNAME]: [SCOPE_ENUM.LOCAL],
[ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL]: [SCOPE_ENUM.LOCAL],
[ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL_COLLECTION]: [SCOPE_ENUM.LOCAL],
[ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL]: [SCOPE_ENUM.LOCAL],
[ACCOUNT_PROPERTY_READABLE_ENUM.HEADLINE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.ORGANISATION]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.PHONE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.ROLE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.TWITTER]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.WEBSITE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
})
@ -90,28 +131,32 @@ export const SCOPE_SUFFIX = 'Scope'
* *Used for federation control*
*/
export const SCOPE_PROPERTY_ENUM = Object.freeze({
[SCOPE_ENUM.LOCAL]: {
name: SCOPE_ENUM.LOCAL,
displayName: t('settings', 'Local'),
tooltip: t('settings', 'Only visible to people on this instance and guests'),
iconClass: 'icon-password',
},
[SCOPE_ENUM.PRIVATE]: {
name: SCOPE_ENUM.PRIVATE,
displayName: t('settings', 'Private'),
tooltip: t('settings', 'Only visible to people matched via phone number integration through Talk on mobile'),
tooltipDisabled: t('settings', 'Unavailable as this property is required for core functionality including file sharing and calendar invitations\n\nOnly visible to people matched via phone number integration through Talk on mobile'),
iconClass: 'icon-phone',
},
[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',
},
[SCOPE_ENUM.FEDERATED]: {
name: SCOPE_ENUM.FEDERATED,
displayName: t('settings', 'Federated'),
tooltip: t('settings', 'Only synchronize to trusted servers'),
tooltipDisabled: t('settings', 'Unavailable as publishing user specific data to the lookup server is not allowed, contact your system administrator if you have any questions\n\nOnly synchronize to trusted servers'),
iconClass: 'icon-contacts-dark',
},
[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', 'Unavailable as publishing user specific data to the lookup server is not allowed, contact your system administrator if you have any questions\n\nSynchronize to trusted servers and the global and public address book'),
iconClass: 'icon-link',
},
})

View file

@ -0,0 +1,50 @@
/**
* @copyright 2021 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/*
* SYNC to be kept in sync with `core/Db/ProfileConfig.php`
*/
/** Enum of profile visibility constants */
export const VISIBILITY_ENUM = Object.freeze({
SHOW: 'show',
SHOW_USERS_ONLY: 'show_users_only',
HIDE: 'hide',
})
/**
* Enum of profile visibility constants to properties
*/
export const VISIBILITY_PROPERTY_ENUM = Object.freeze({
[VISIBILITY_ENUM.SHOW]: {
name: VISIBILITY_ENUM.SHOW,
label: t('settings', 'Show to everyone'),
},
[VISIBILITY_ENUM.SHOW_USERS_ONLY]: {
name: VISIBILITY_ENUM.SHOW_USERS_ONLY,
label: t('settings', 'Show to logged in users only'),
},
[VISIBILITY_ENUM.HIDE]: {
name: VISIBILITY_ENUM.HIDE,
label: t('settings', 'Hide'),
},
})

View file

@ -22,6 +22,7 @@
import Vue from 'vue'
import { getRequestToken } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import '@nextcloud/dialogs/styles/toast.scss'
@ -30,6 +31,13 @@ import logger from './logger'
import DisplayNameSection from './components/PersonalInfo/DisplayNameSection/DisplayNameSection'
import EmailSection from './components/PersonalInfo/EmailSection/EmailSection'
import LanguageSection from './components/PersonalInfo/LanguageSection/LanguageSection'
import ProfileSection from './components/PersonalInfo/ProfileSection/ProfileSection'
import OrganisationSection from './components/PersonalInfo/OrganisationSection/OrganisationSection'
import RoleSection from './components/PersonalInfo/RoleSection/RoleSection'
import HeadlineSection from './components/PersonalInfo/HeadlineSection/HeadlineSection'
import BiographySection from './components/PersonalInfo/BiographySection/BiographySection'
import ProfileVisibilitySection from './components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection'
import VisibilityDropdown from './components/PersonalInfo/shared/VisibilityDropdown'
__webpack_nonce__ = btoa(getRequestToken())
@ -45,7 +53,40 @@ Vue.mixin({
const DisplayNameView = Vue.extend(DisplayNameSection)
const EmailView = Vue.extend(EmailSection)
const LanguageView = Vue.extend(LanguageSection)
const ProfileView = Vue.extend(ProfileSection)
const OrganisationView = Vue.extend(OrganisationSection)
const RoleView = Vue.extend(RoleSection)
const HeadlineView = Vue.extend(HeadlineSection)
const BiographyView = Vue.extend(BiographySection)
const ProfileVisibilityView = Vue.extend(ProfileVisibilitySection)
const VisibilityDropdownView = Vue.extend(VisibilityDropdown)
new DisplayNameView().$mount('#vue-displaynamesection')
new EmailView().$mount('#vue-emailsection')
new LanguageView().$mount('#vue-languagesection')
new DisplayNameView().$mount('#vue-displayname-section')
new EmailView().$mount('#vue-email-section')
new LanguageView().$mount('#vue-language-section')
new ProfileView().$mount('#vue-profile-section')
new OrganisationView().$mount('#vue-organisation-section')
new RoleView().$mount('#vue-role-section')
new HeadlineView().$mount('#vue-headline-section')
new BiographyView().$mount('#vue-biography-section')
new ProfileVisibilityView().$mount('#vue-profile-visibility-section')
// Profile visibility dropdowns
const { profileConfig } = loadState('settings', 'profileParameters', {})
const visibilityDropdownParamIds = [
'avatar',
'phone',
'address',
'website',
'twitter',
]
for (const paramId of visibilityDropdownParamIds) {
const { displayId } = profileConfig[paramId]
new VisibilityDropdownView({
propsData: {
paramId,
displayId,
},
}).$mount(`#vue-profile-visibility-${paramId}`)
}

View file

@ -25,42 +25,50 @@ import { getCurrentUser } from '@nextcloud/auth'
import { generateOcsUrl } from '@nextcloud/router'
import confirmPassword from '@nextcloud/password-confirmation'
import { ACCOUNT_PROPERTY_ENUM, SCOPE_SUFFIX } from '../../constants/AccountPropertyConstants'
import { SCOPE_SUFFIX } from '../../constants/AccountPropertyConstants'
/**
* Save the primary display name of the user
* Save the primary account property value for the user
*
* @param {string} displayName the primary display name
* @param {string} accountProperty the account property
* @param {string|boolean} value the primary value
* @returns {object}
*/
export const savePrimaryDisplayName = async(displayName) => {
export const savePrimaryAccountProperty = async(accountProperty, value) => {
// TODO allow boolean values on backend route handler
// Convert boolean to string for compatibility
if (typeof value === 'boolean') {
value = value ? '1' : '0'
}
const userId = getCurrentUser().uid
const url = generateOcsUrl('cloud/users/{userId}', { userId })
await confirmPassword()
const res = await axios.put(url, {
key: ACCOUNT_PROPERTY_ENUM.DISPLAYNAME,
value: displayName,
key: accountProperty,
value,
})
return res.data
}
/**
* Save the federation scope for the primary display name of the user
* Save the federation scope of the primary account property for the user
*
* @param {string} accountProperty the account property
* @param {string} scope the federation scope
* @returns {object}
*/
export const savePrimaryDisplayNameScope = async(scope) => {
export const savePrimaryAccountPropertyScope = async(accountProperty, scope) => {
const userId = getCurrentUser().uid
const url = generateOcsUrl('cloud/users/{userId}', { userId })
await confirmPassword()
const res = await axios.put(url, {
key: `${ACCOUNT_PROPERTY_ENUM.DISPLAYNAME}${SCOPE_SUFFIX}`,
key: `${accountProperty}${SCOPE_SUFFIX}`,
value: scope,
})

View file

@ -1,5 +1,5 @@
/**
* @copyright 2021, Christopher Ng <chrng8@gmail.com>
* @copyright 2021 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
@ -12,7 +12,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
@ -25,23 +25,22 @@ import { getCurrentUser } from '@nextcloud/auth'
import { generateOcsUrl } from '@nextcloud/router'
import confirmPassword from '@nextcloud/password-confirmation'
import { SETTING_PROPERTY_ENUM } from '../../constants/AccountPropertyConstants'
/**
* Save the language of the user
* Save the visibility of the profile parameter
*
* @param {string} languageCode the language code
* @param {string} paramId the profile parameter ID
* @param {string} visibility the visibility
* @returns {object}
*/
export const saveLanguage = async(languageCode) => {
export const saveProfileParameterVisibility = async(paramId, visibility) => {
const userId = getCurrentUser().uid
const url = generateOcsUrl('cloud/users/{userId}', { userId })
const url = generateOcsUrl('/profile/{userId}', { userId })
await confirmPassword()
const res = await axios.put(url, {
key: SETTING_PROPERTY_ENUM.LANGUAGE,
value: languageCode,
paramId,
visibility,
})
return res.data

View file

@ -29,12 +29,14 @@
import { VALIDATE_EMAIL_REGEX } from '../constants/AccountPropertyConstants'
/**
* Validate the display name input
* Validate the string input
*
* *Generic validator just to check that input is not an empty string*
*
* @param {string} input the input
* @returns {boolean}
*/
export function validateDisplayName(input) {
export function validateStringInput(input) {
return input !== ''
}
@ -67,3 +69,13 @@ export function validateLanguage(input) {
&& input.name !== ''
&& input.name !== undefined
}
/**
* Validate boolean input
*
* @param {boolean} input the input
* @returns {boolean}
*/
export function validateBoolean(input) {
return typeof input === 'boolean'
}

View file

@ -1,4 +1,5 @@
<?php
/**
* @copyright Copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de>
*
@ -51,13 +52,13 @@ script('settings', [
<div id="displayavatar">
<div class="avatardiv"></div>
<div class="warning hidden"></div>
<?php if ($_['avatarChangeSupported']): ?>
<?php if ($_['avatarChangeSupported']) : ?>
<label for="uploadavatar" class="inlineblock button icon-upload svg" id="uploadavatarbutton" title="<?php p($l->t('Upload new')); ?>" tabindex="0"></label>
<button class="inlineblock button icon-folder svg" id="selectavatar" title="<?php p($l->t('Select from Files')); ?>"></button>
<button class="hidden button icon-delete svg" id="removeavatar" title="<?php p($l->t('Remove image')); ?>"></button>
<input type="file" name="files[]" id="uploadavatar" class="hiddenuploadfield" accept="image/*">
<p><em><?php p($l->t('png or jpg, max. 20 MB')); ?></em></p>
<?php else: ?>
<?php else : ?>
<?php p($l->t('Picture provided by original account')); ?>
<?php endif; ?>
</div>
@ -69,8 +70,9 @@ script('settings', [
</div>
</div>
<span class="icon-checkmark hidden"></span>
<span class="icon-error hidden" ></span>
<span class="icon-error hidden"></span>
<input type="hidden" id="avatarscope" value="<?php p($_['avatarScope']) ?>">
<div id="vue-profile-visibility-avatar"></div>
</form>
</div>
<div class="personal-settings-setting-box personal-settings-group-box section">
@ -84,26 +86,30 @@ script('settings', [
<div id="quota" class="personal-info icon-quota">
<div class="quotatext-bg">
<p class="quotatext">
<?php if ($_['quota'] === \OCP\Files\FileInfo::SPACE_UNLIMITED): ?>
<?php print_unescaped($l->t('You are using <strong>%s</strong>',
[$_['usage']]));?>
<?php else: ?>
<?php print_unescaped($l->t('You are using <strong>%1$s</strong> of <strong>%2$s</strong> (<strong>%3$s %%</strong>)',
[$_['usage'], $_['total_space'], $_['usage_relative']]));?>
<?php if ($_['quota'] === \OCP\Files\FileInfo::SPACE_UNLIMITED) : ?>
<?php print_unescaped($l->t(
'You are using <strong>%s</strong>',
[$_['usage']]
)); ?>
<?php else : ?>
<?php print_unescaped($l->t(
'You are using <strong>%1$s</strong> of <strong>%2$s</strong> (<strong>%3$s %%</strong>)',
[$_['usage'], $_['total_space'], $_['usage_relative']]
)); ?>
<?php endif ?>
</p>
</div>
<progress value="<?php p($_['usage_relative']); ?>" max="100"<?php if ($_['usage_relative'] > 80): ?> class="warn" <?php endif; ?>></progress>
<progress value="<?php p($_['usage_relative']); ?>" max="100" <?php if ($_['usage_relative'] > 80) : ?> class="warn" <?php endif; ?>></progress>
</div>
</div>
</div>
<div class="personal-settings-container">
<div class="personal-settings-setting-box">
<div id="vue-displaynamesection" class="section"></div>
<div id="vue-displayname-section"></div>
</div>
<div class="personal-settings-setting-box">
<div id="vue-emailsection" class="section"></div>
<div id="vue-email-section"></div>
</div>
<div class="personal-settings-setting-box">
<form id="phoneform" class="section">
@ -115,13 +121,11 @@ script('settings', [
</span>
</a>
</h3>
<input type="tel" id="phone" name="phone"
value="<?php p($_['phone']) ?>"
placeholder="<?php p($l->t('Your phone number')); ?>"
autocomplete="on" autocapitalize="none" autocorrect="off" />
<input type="tel" id="phone" name="phone" value="<?php p($_['phone']) ?>" placeholder="<?php p($l->t('Your phone number')); ?>" autocomplete="on" autocapitalize="none" autocorrect="off" />
<span class="icon-checkmark hidden"></span>
<span class="icon-error hidden" ></span>
<span class="icon-error hidden"></span>
<input type="hidden" id="phonescope" value="<?php p($_['phoneScope']) ?>">
<div id="vue-profile-visibility-phone"></div>
</form>
</div>
<div class="personal-settings-setting-box">
@ -134,13 +138,11 @@ script('settings', [
</span>
</a>
</h3>
<input type="text" id="address" name="address"
placeholder="<?php p($l->t('Your postal address')); ?>"
value="<?php p($_['address']) ?>"
autocomplete="on" autocapitalize="none" autocorrect="off" />
<input type="text" id="address" name="address" placeholder="<?php p($l->t('Your postal address')); ?>" value="<?php p($_['address']) ?>" autocomplete="on" autocapitalize="none" autocorrect="off" />
<span class="icon-checkmark hidden"></span>
<span class="icon-error hidden" ></span>
<span class="icon-error hidden"></span>
<input type="hidden" id="addressscope" value="<?php p($_['addressScope']) ?>">
<div id="vue-profile-visibility-address"></div>
</form>
</div>
<div class="personal-settings-setting-box">
@ -154,10 +156,10 @@ script('settings', [
</a>
</h3>
<?php if ($_['lookupServerUploadEnabled']) { ?>
<div class="verify <?php if ($_['website'] === '' || $_['websiteScope'] !== 'public') {
p('hidden');
} ?>">
<img id="verify-website" title="<?php p($_['websiteMessage']); ?>" data-status="<?php p($_['websiteVerification']) ?>" src="
<div class="verify <?php if ($_['website'] === '' || $_['websiteScope'] !== 'public') {
p('hidden');
} ?>">
<img id="verify-website" title="<?php p($_['websiteMessage']); ?>" data-status="<?php p($_['websiteVerification']) ?>" src="
<?php
switch ($_['websiteVerification']) {
case \OC\Accounts\AccountManager::VERIFICATION_IN_PROGRESS:
@ -169,27 +171,23 @@ script('settings', [
default:
p(image_path('core', 'actions/verify.svg'));
}
?>"
<?php if ($_['websiteVerification'] === \OC\Accounts\AccountManager::VERIFICATION_IN_PROGRESS || $_['websiteVerification'] === \OC\Accounts\AccountManager::NOT_VERIFIED) {
?>" <?php if ($_['websiteVerification'] === \OC\Accounts\AccountManager::VERIFICATION_IN_PROGRESS || $_['websiteVerification'] === \OC\Accounts\AccountManager::NOT_VERIFIED) {
print_unescaped(' class="verify-action"');
} ?>
>
<div class="verification-dialog popovermenu bubble menu">
<div class="verification-dialog-content">
<p class="explainVerification"></p>
<p class="verificationCode"></p>
<p><?php p($l->t('It can take up to 24 hours before the account is displayed as verified.'));?></p>
} ?>>
<div class="verification-dialog popovermenu bubble menu">
<div class="verification-dialog-content">
<p class="explainVerification"></p>
<p class="verificationCode"></p>
<p><?php p($l->t('It can take up to 24 hours before the account is displayed as verified.')); ?></p>
</div>
</div>
</div>
</div>
<?php } ?>
<input type="url" name="website" id="website" value="<?php p($_['website']); ?>"
placeholder="<?php p($l->t('Link https://…')); ?>"
autocomplete="on" autocapitalize="none" autocorrect="off"
/>
<input type="url" name="website" id="website" value="<?php p($_['website']); ?>" placeholder="<?php p($l->t('Link https://…')); ?>" autocomplete="on" autocapitalize="none" autocorrect="off" />
<span class="icon-checkmark hidden"></span>
<span class="icon-error hidden" ></span>
<span class="icon-error hidden"></span>
<input type="hidden" id="websitescope" value="<?php p($_['websiteScope']) ?>">
<div id="vue-profile-visibility-website"></div>
</form>
</div>
<div class="personal-settings-setting-box">
@ -203,10 +201,10 @@ script('settings', [
</a>
</h3>
<?php if ($_['lookupServerUploadEnabled']) { ?>
<div class="verify <?php if ($_['twitter'] === '' || $_['twitterScope'] !== 'public') {
<div class="verify <?php if ($_['twitter'] === '' || $_['twitterScope'] !== 'public') {
p('hidden');
} ?>">
<img id="verify-twitter" title="<?php p($_['twitterMessage']); ?>" data-status="<?php p($_['twitterVerification']) ?>" src="
<img id="verify-twitter" title="<?php p($_['twitterMessage']); ?>" data-status="<?php p($_['twitterVerification']) ?>" src="
<?php
switch ($_['twitterVerification']) {
case \OC\Accounts\AccountManager::VERIFICATION_IN_PROGRESS:
@ -218,60 +216,74 @@ script('settings', [
default:
p(image_path('core', 'actions/verify.svg'));
}
?>"
<?php if ($_['twitterVerification'] === \OC\Accounts\AccountManager::VERIFICATION_IN_PROGRESS || $_['twitterVerification'] === \OC\Accounts\AccountManager::NOT_VERIFIED) {
?>" <?php if ($_['twitterVerification'] === \OC\Accounts\AccountManager::VERIFICATION_IN_PROGRESS || $_['twitterVerification'] === \OC\Accounts\AccountManager::NOT_VERIFIED) {
print_unescaped(' class="verify-action"');
} ?>
>
<div class="verification-dialog popovermenu bubble menu">
<div class="verification-dialog-content">
<p class="explainVerification"></p>
<p class="verificationCode"></p>
<p><?php p($l->t('It can take up to 24 hours before the account is displayed as verified.'));?></p>
} ?>>
<div class="verification-dialog popovermenu bubble menu">
<div class="verification-dialog-content">
<p class="explainVerification"></p>
<p class="verificationCode"></p>
<p><?php p($l->t('It can take up to 24 hours before the account is displayed as verified.')); ?></p>
</div>
</div>
</div>
</div>
<?php } ?>
<input type="text" name="twitter" id="twitter" value="<?php p($_['twitter']); ?>"
placeholder="<?php p($l->t('Twitter handle @…')); ?>"
autocomplete="on" autocapitalize="none" autocorrect="off"
/>
<input type="text" name="twitter" id="twitter" value="<?php p($_['twitter']); ?>" placeholder="<?php p($l->t('Twitter handle @…')); ?>" autocomplete="on" autocapitalize="none" autocorrect="off" />
<span class="icon-checkmark hidden"></span>
<span class="icon-error hidden" ></span>
<span class="icon-error hidden"></span>
<input type="hidden" id="twitterscope" value="<?php p($_['twitterScope']) ?>">
<div id="vue-profile-visibility-twitter"></div>
</form>
</div>
<div class="personal-settings-setting-box">
<div id="vue-organisation-section"></div>
</div>
<div class="personal-settings-setting-box">
<div id="vue-role-section"></div>
</div>
<div class="personal-settings-setting-box">
<div id="vue-headline-section"></div>
</div>
<div class="personal-settings-setting-box">
<div id="vue-biography-section"></div>
</div>
</div>
<div class="profile-settings-container">
<div class="personal-settings-setting-box">
<div id="vue-profile-section"></div>
</div>
<div class="personal-settings-setting-box">
<div id="vue-profile-visibility-section"></div>
</div>
<div class="personal-settings-setting-box personal-settings-language-box">
<div id="vue-languagesection" class="section"></div>
<div id="vue-language-section"></div>
</div>
<div class="personal-settings-setting-box personal-settings-locale-box">
<?php if (isset($_['activelocale'])) { ?>
<form id="locale" class="section">
<h3>
<label for="localeinput"><?php p($l->t('Locale'));?></label>
<label for="localeinput"><?php p($l->t('Locale')); ?></label>
</h3>
<select id="localeinput" name="lang" data-placeholder="<?php p($l->t('Locale'));?>">
<option value="<?php p($_['activelocale']['code']);?>">
<?php p($l->t($_['activelocale']['name']));?>
<select id="localeinput" name="lang" data-placeholder="<?php p($l->t('Locale')); ?>">
<option value="<?php p($_['activelocale']['code']); ?>">
<?php p($l->t($_['activelocale']['name'])); ?>
</option>
<optgroup label=""></optgroup>
<?php foreach ($_['localesForLanguage'] as $locale):?>
<option value="<?php p($locale['code']);?>">
<?php p($l->t($locale['name']));?>
<?php foreach ($_['localesForLanguage'] as $locale) : ?>
<option value="<?php p($locale['code']); ?>">
<?php p($l->t($locale['name'])); ?>
</option>
<?php endforeach;?>
<?php endforeach; ?>
<optgroup label=""></optgroup>
<option value="<?php p($_['activelocale']['code']);?>">
<?php p($l->t($_['activelocale']['name']));?>
<option value="<?php p($_['activelocale']['code']); ?>">
<?php p($l->t($_['activelocale']['name'])); ?>
</option>
<?php foreach ($_['locales'] as $locale):?>
<option value="<?php p($locale['code']);?>">
<?php p($l->t($locale['name']));?>
<?php foreach ($_['locales'] as $locale) : ?>
<option value="<?php p($locale['code']); ?>">
<?php p($l->t($locale['name'])); ?>
</option>
<?php endforeach;?>
<?php endforeach; ?>
</select>
<div id="localeexample" class="personal-info icon-timezone">
<p>

View file

@ -109,8 +109,14 @@ $invert: luma($color-primary) > 0.6;
background-image: $image-logo;
}
#body-user #header, #body-settings #header, #body-public #header {
@include faded-background;
#body-user,
#body-settings,
#body-public {
#header,
.profile__header,
.preview-card__header {
@include faded-background;
}
}
#body-login,

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

View file

@ -24,17 +24,27 @@ declare(strict_types=1);
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\UserStatus\Listener;
use OCA\UserStatus\AppInfo\Application;
use OCA\UserStatus\Service\JSDataService;
use OCP\Accounts\IAccountManager;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IInitialStateService;
use OCP\IUserSession;
class BeforeTemplateRenderedListener implements IEventListener {
use \OC\Profile\TProfileHelper;
/** @var IAccountManager */
private $accountManager;
/** @var IUserSession */
private $userSession;
/** @var IInitialStateService */
private $initialState;
@ -45,11 +55,19 @@ class BeforeTemplateRenderedListener implements IEventListener {
/**
* BeforeTemplateRenderedListener constructor.
*
* @param IAccountManager $accountManager
* @param IUserSession $userSession
* @param IInitialStateService $initialState
* @param JSDataService $jsDataService
*/
public function __construct(IInitialStateService $initialState,
JSDataService $jsDataService) {
public function __construct(
IAccountManager $accountManager,
IUserSession $userSession,
IInitialStateService $initialState,
JSDataService $jsDataService
) {
$this->accountManager = $accountManager;
$this->userSession = $userSession;
$this->initialState = $initialState;
$this->jsDataService = $jsDataService;
}
@ -58,6 +76,12 @@ class BeforeTemplateRenderedListener implements IEventListener {
* @inheritDoc
*/
public function handle(Event $event): void {
$user = $this->userSession->getUser();
if ($user === null) {
return;
}
$account = $this->accountManager->getAccount($user);
if (!($event instanceof BeforeTemplateRenderedEvent)) {
// Unrelated
return;
@ -71,6 +95,10 @@ class BeforeTemplateRenderedListener implements IEventListener {
return $this->jsDataService;
});
$this->initialState->provideLazyInitialState(Application::APP_ID, 'profileEnabled', function () use ($account) {
return ['profileEnabled' => $this->isProfileEnabled($account)];
});
\OCP\Util::addScript('user_status', 'user-status-menu');
\OCP\Util::addStyle('user_status', 'user-status-menu');
}

View file

@ -23,12 +23,20 @@
<li>
<div class="user-status-menu-item">
<!-- Username display -->
<span
<a
v-if="!inline"
class="user-status-menu-item__header"
:title="displayName">
{{ displayName }}
</span>
:href="profilePageLink"
@click="loadProfilePage">
<div class="user-status-menu-item__header-content">
<div class="user-status-menu-item__header-content-displayname">{{ displayName }}</div>
<div v-if="!loadingProfilePage" class="user-status-menu-item__header-content-placeholder" />
<div v-else class="icon-loading-small" />
</div>
<div v-if="profileEnabled">
{{ t('user_status', 'View profile') }}
</div>
</a>
<!-- Status modal toggle -->
<toggle :is="inline ? 'button' : 'a'"
@ -50,11 +58,16 @@
<script>
import { getCurrentUser } from '@nextcloud/auth'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { generateUrl } from '@nextcloud/router'
import debounce from 'debounce'
import { sendHeartbeat } from './services/heartbeatService'
import OnlineStatusMixin from './mixins/OnlineStatusMixin'
const { profileEnabled } = loadState('user_status', 'profileEnabled', false)
export default {
name: 'UserStatus',
@ -72,21 +85,30 @@ export default {
data() {
return {
isModalOpen: false,
displayName: getCurrentUser().displayName,
heartbeatInterval: null,
setAwayTimeout: null,
mouseMoveListener: null,
isAway: false,
isModalOpen: false,
loadingProfilePage: false,
mouseMoveListener: null,
profileEnabled,
setAwayTimeout: null,
}
},
computed: {
/**
* The display-name of the current user
* The profile page link
*
* @returns {String}
* @returns {String|null}
*/
displayName() {
return getCurrentUser().displayName
profilePageLink() {
if (this.profileEnabled) {
return generateUrl('/u/{userId}', { userId: getCurrentUser().uid })
}
// Since an anchor element is used rather than a button,
// this hack removes href if the profile is disabled so that disabling pointer-events is not needed to prevent a click from opening a page
// and to allow the hover event for styling
return null
},
},
@ -95,6 +117,9 @@ export default {
* and stores it in Vuex
*/
mounted() {
subscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
subscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
this.$store.dispatch('loadStatusFromInitialState')
if (OC.config.session_keepalive) {
@ -130,11 +155,27 @@ export default {
* Some housekeeping before destroying the component
*/
beforeDestroy() {
unsubscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
unsubscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
window.removeEventListener('mouseMove', this.mouseMoveListener)
clearInterval(this.heartbeatInterval)
},
methods: {
handleDisplayNameUpdate(displayName) {
this.displayName = displayName
},
handleProfileEnabledUpdate(profileEnabled) {
this.profileEnabled = profileEnabled
},
loadProfilePage() {
if (this.profileEnabled) {
this.loadingProfilePage = true
}
},
/**
* Opens the modal to set a custom status
*/
@ -171,20 +212,51 @@ export default {
</script>
<style lang="scss" scoped>
$max-width-user-status: 200px;
.user-status-menu-item {
&__header {
display: block;
overflow: hidden;
box-sizing: border-box;
max-width: $max-width-user-status;
padding: 10px 12px 5px 38px;
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
opacity: 1;
color: var(--color-text-maxcontrast);
display: flex !important;
flex-direction: column !important;
width: auto !important;
height: 44px * 1.5 !important;
padding: 10px 12px 5px 12px !important;
align-items: flex-start !important;
color: var(--color-main-text) !important;
&:not([href]) {
height: var(--header-menu-item-height) !important;
color: var(--color-text-maxcontrast) !important;
cursor: default !important;
& * {
cursor: default !important;
}
&:hover {
background-color: transparent !important;
}
}
&-content {
display: inline-flex !important;
font-weight: bold !important;
gap: 0 10px !important;
width: auto;
&-displayname {
width: auto;
}
&-placeholder {
width: 16px !important;
height: 24px !important;
margin-right: 10px !important;
visibility: hidden !important;
}
}
span {
color: var(--color-text-maxcontrast) !important;
}
}
&__toggle {

View file

@ -67,6 +67,11 @@ Feature: provisioning
| address |
| website |
| twitter |
| organisation |
| role |
| headline |
| biography |
| profile_enabled |
Given As an "brand-new-user"
Then user "brand-new-user" has editable fields
| displayname |
@ -76,6 +81,11 @@ Feature: provisioning
| address |
| website |
| twitter |
| organisation |
| role |
| headline |
| biography |
| profile_enabled |
Then user "self" has editable fields
| displayname |
| email |
@ -84,6 +94,11 @@ Feature: provisioning
| address |
| website |
| twitter |
| organisation |
| role |
| headline |
| biography |
| profile_enabled |
Scenario: Edit a user
Given As an "admin"

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
/**
* @copyright 2021 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OC\Core\Controller;
use OC\Core\Db\ProfileConfigMapper;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCS\OCSBadRequestException;
use OCP\AppFramework\OCS\OCSForbiddenException;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\IUserSession;
use OC\Profile\ProfileManager;
class ProfileApiController extends OCSController {
/** @var ProfileConfigMapper */
private $configMapper;
/** @var ProfileManager */
private $profileManager;
/** @var IUserManager */
private $userManager;
/** @var IUserSession */
private $userSession;
public function __construct(
IRequest $request,
ProfileConfigMapper $configMapper,
ProfileManager $profileManager,
IUserManager $userManager,
IUserSession $userSession
) {
parent::__construct('core', $request);
$this->configMapper = $configMapper;
$this->profileManager = $profileManager;
$this->userManager = $userManager;
$this->userSession = $userSession;
}
/**
* @NoAdminRequired
* @NoSubAdminRequired
* @PasswordConfirmationRequired
*/
public function setVisibility(string $targetUserId, string $paramId, string $visibility): DataResponse {
$requestingUser = $this->userSession->getUser();
$targetUser = $this->userManager->get($targetUserId);
if (!$this->userManager->userExists($targetUserId)) {
throw new OCSNotFoundException('User does not exist');
}
if ($requestingUser !== $targetUser) {
throw new OCSForbiddenException('Users can only edit their own visibility settings');
}
// Ensure that a profile config is created in the database
$this->profileManager->getProfileConfig($targetUser, $targetUser);
$config = $this->configMapper->get($targetUserId);
if (!in_array($paramId, array_keys($config->getVisibilityMap()), true)) {
throw new OCSBadRequestException('User does not have a profile parameter with ID: ' . $paramId);
}
$config->setVisibility($paramId, $visibility);
$this->configMapper->update($config);
return new DataResponse();
}
}

View file

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
/**
* @copyright 2021 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OC\Core\Controller;
use OCP\Accounts\IAccountManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\IUserSession;
use OC\Profile\ProfileManager;
use OCP\UserStatus\IManager as IUserStatusManager;
class ProfilePageController extends Controller {
use \OC\Profile\TProfileHelper;
/** @var IInitialState */
private $initialStateService;
/** @var IAccountManager */
private $accountManager;
/** @var ProfileManager */
private $profileManager;
/** @var IUserManager */
private $userManager;
/** @var IUserSession */
private $userSession;
/** @var IUserStatusManager */
private $userStatusManager;
public function __construct(
$appName,
IRequest $request,
IInitialState $initialStateService,
IAccountManager $accountManager,
ProfileManager $profileManager,
IUserManager $userManager,
IUserSession $userSession,
IUserStatusManager $userStatusManager
) {
parent::__construct($appName, $request);
$this->initialStateService = $initialStateService;
$this->accountManager = $accountManager;
$this->profileManager = $profileManager;
$this->userManager = $userManager;
$this->userSession = $userSession;
$this->userStatusManager = $userStatusManager;
}
/**
* @PublicPage
* @NoCSRFRequired
* @NoAdminRequired
* @NoSubAdminRequired
*/
public function index(string $targetUserId): TemplateResponse {
if (!$this->userManager->userExists($targetUserId)) {
return new TemplateResponse(
'core',
'404-profile',
[],
TemplateResponse::RENDER_AS_GUEST,
);
}
$visitingUser = $this->userSession->getUser();
$targetUser = $this->userManager->get($targetUserId);
$targetAccount = $this->accountManager->getAccount($targetUser);
if (!$this->isProfileEnabled($targetAccount)) {
return new TemplateResponse(
'core',
'404-profile',
[],
TemplateResponse::RENDER_AS_GUEST,
);
}
$userStatuses = $this->userStatusManager->getUserStatuses([$targetUserId]);
$status = array_shift($userStatuses);
if (!empty($status)) {
$this->initialStateService->provideInitialState('status', [
'icon' => $status->getIcon(),
'message' => $status->getMessage(),
]);
}
$this->initialStateService->provideInitialState(
'profileParameters',
$this->profileManager->getProfileParams($targetUser, $visitingUser),
);
\OCP\Util::addScript('core', 'dist/profile');
return new TemplateResponse(
'core',
'profile',
[],
$this->userSession->isLoggedIn() ? TemplateResponse::RENDER_AS_USER : TemplateResponse::RENDER_AS_PUBLIC,
);
}
}

172
core/Db/ProfileConfig.php Normal file
View file

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
/**
* @copyright 2021 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OC\Core\Db;
use function Safe\json_decode;
use function Safe\json_encode;
use \JsonSerializable;
use OCP\Accounts\IAccountManager;
use OCP\AppFramework\Db\Entity;
use OCP\Profile\ParameterDoesNotExistException;
/**
* @method string getUserId()
* @method void setUserId(string $userId)
* @method string getConfig()
* @method void setConfig(string $config)
*/
class ProfileConfig extends Entity implements JsonSerializable {
/**
* Visible to users, guests, and public access
*
* @since 23.0.0
*/
public const VISIBILITY_SHOW = 'show';
/**
* Visible to users and guests
*
* @since 23.0.0
*/
public const VISIBILITY_SHOW_USERS_ONLY = 'show_users_only';
/**
* Visible to nobody
*
* @since 23.0.0
*/
public const VISIBILITY_HIDE = 'hide';
/**
* Default account property visibility
*
* @since 23.0.0
*/
public const DEFAULT_PROPERTY_VISIBILITY = [
IAccountManager::PROPERTY_ADDRESS => self::VISIBILITY_SHOW_USERS_ONLY,
IAccountManager::PROPERTY_AVATAR => self::VISIBILITY_SHOW,
IAccountManager::PROPERTY_BIOGRAPHY => self::VISIBILITY_SHOW,
IAccountManager::PROPERTY_DISPLAYNAME => self::VISIBILITY_SHOW,
IAccountManager::PROPERTY_HEADLINE => self::VISIBILITY_SHOW,
IAccountManager::PROPERTY_ORGANISATION => self::VISIBILITY_SHOW,
IAccountManager::PROPERTY_ROLE => self::VISIBILITY_SHOW,
IAccountManager::PROPERTY_EMAIL => self::VISIBILITY_SHOW_USERS_ONLY,
IAccountManager::PROPERTY_PHONE => self::VISIBILITY_SHOW_USERS_ONLY,
IAccountManager::PROPERTY_TWITTER => self::VISIBILITY_SHOW,
IAccountManager::PROPERTY_WEBSITE => self::VISIBILITY_SHOW,
];
/**
* Default visibility
*
* @since 23.0.0
*/
public const DEFAULT_VISIBILITY = self::VISIBILITY_SHOW_USERS_ONLY;
/** @var string */
protected $userId;
/** @var string */
protected $config;
public function __construct() {
$this->addType('userId', 'string');
$this->addType('config', 'string');
}
/**
* Returns the config in an associative array
*/
public function getConfigArray(): array {
return json_decode($this->config, true);
}
/**
* Set the config
*/
public function setConfigArray(array $config): void {
$this->setConfig(json_encode($config));
}
/**
* Returns the visibility map in an associative array
*/
public function getVisibilityMap(): array {
$config = $this->getConfigArray();
$visibilityMap = [];
foreach ($config as $paramId => $paramConfig) {
$visibilityMap[$paramId] = $paramConfig['visibility'];
}
return $visibilityMap;
}
/**
* Set the visibility map
*/
public function setVisibilityMap(array $visibilityMap): void {
$config = $this->getConfigArray();
foreach ($visibilityMap as $paramId => $visibility) {
$config[$paramId] = array_merge(
$config[$paramId] ?: [],
['visibility' => $visibility],
);
}
$this->setConfigArray($config);
}
/**
* Returns the visibility of the parameter
*
* @throws ParameterDoesNotExistException
*/
public function getVisibility(string $paramId): string {
$visibilityMap = $this->getVisibilityMap();
if (isset($visibilityMap[$paramId])) {
return $visibilityMap[$paramId];
}
throw new ParameterDoesNotExistException($paramId);
}
/**
* Set the visibility of the parameter
*/
public function setVisibility(string $paramId, string $visibility): void {
$visibilityMap = $this->getVisibilityMap();
$visibilityMap[$paramId] = $visibility;
$this->setVisibilityMap($visibilityMap);
}
public function jsonSerialize(): array {
return [
'userId' => $this->userId,
'config' => $this->getConfigArray(),
];
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/**
* @copyright 2021 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OC\Core\Db;
use OCP\AppFramework\Db\QBMapper;
use OCP\IDBConnection;
class ProfileConfigMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'profile_config', ProfileConfig::class);
}
public function get(string $userId): ProfileConfig {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
return $this->findEntity($qb);
}
public function getArray(string $userId): array {
return $this->get($userId)->getConfigArray();
}
}

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/**
* @copyright 2021 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OC\Core\Migrations;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version23000Date20210930122352 extends SimpleMigrationStep {
private const TABLE_NAME = 'profile_config';
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$hasTable = $schema->hasTable(self::TABLE_NAME);
if (!$hasTable) {
$table = $schema->createTable(self::TABLE_NAME);
$table->addColumn('id', Types::INTEGER, [
'autoincrement' => true,
'notnull' => true,
]);
$table->addColumn('user_id', Types::STRING, [
'notnull' => true,
'length' => 64,
]);
$table->addColumn('config', Types::TEXT, [
'notnull' => true,
]);
$table->setPrimaryKey(['id']);
$table->addUniqueIndex(['user_id'], 'user_id');
return $schema;
}
return null;
}
}

View file

@ -68,4 +68,3 @@
--header-height: #{$header-height};
}

View file

@ -112,6 +112,8 @@ $sidebar-min-width: 300px;
$sidebar-max-width: 500px;
$list-min-width: 200px;
$list-max-width: 300px;
$header-menu-item-height: 44px;
$header-menu-profile-item-height: 66px;
// mobile. Keep in sync with core/js/js.js
$breakpoint-mobile: 1024px;

View file

@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4.41333 7.19333C5.37333 9.08 6.92 10.62 8.80667 11.5867L10.2733 10.12C10.4533 9.94 10.72 9.88 10.9533 9.96C11.7 10.2067 12.5067 10.34 13.3333 10.34C13.7 10.34 14 10.64 14 11.0067V13.3333C14 13.7 13.7 14 13.3333 14C7.07333 14 2 8.92667 2 2.66667C2 2.3 2.3 2 2.66667 2H5C5.36667 2 5.66667 2.3 5.66667 2.66667C5.66667 3.5 5.8 4.3 6.04667 5.04667C6.12 5.28 6.06667 5.54 5.88 5.72667L4.41333 7.19333Z" fill="black"/></svg>

After

Width:  |  Height:  |  Size: 523 B

View file

@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 1C8.92826 1 9.8185 1.36875 10.4749 2.02513C11.1313 2.6815 11.5 3.57174 11.5 4.5C11.5 5.42826 11.1313 6.3185 10.4749 6.97487C9.8185 7.63125 8.92826 8 8 8C7.07174 8 6.1815 7.63125 5.52513 6.97487C4.86875 6.3185 4.5 5.42826 4.5 4.5C4.5 3.57174 4.86875 2.6815 5.52513 2.02513C6.1815 1.36875 7.07174 1 8 1V1ZM8 9.75C11.8675 9.75 15 11.3162 15 13.25V15H1V13.25C1 11.3162 4.1325 9.75 8 9.75Z" fill="black"/></svg>

After

Width:  |  Height:  |  Size: 514 B

View file

@ -1,3 +1 @@
<svg enable-background="new 0 0 15 15" version="1.1" viewBox="0 0 15 15" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<path d="m14.982 7c-0.246-3.744-3.238-6.737-6.982-6.983v-0.017h-1v0.017c-3.744 0.246-6.737 3.239-6.983 6.983h-0.017v1h0.017c0.246 3.744 3.239 6.736 6.983 6.982v0.018h1v-0.018c3.744-0.246 6.736-3.238 6.982-6.982h0.018v-1h-0.018zm-10.287-5.365c-0.483 0.642-0.884 1.447-1.176 2.365h-1.498c0.652-1.017 1.578-1.84 2.674-2.365zm-3.197 3.365h1.758c-0.134 0.632-0.219 1.303-0.246 2h-1.991c0.053-0.704 0.219-1.377 0.479-2zm-0.479 3h1.991c0.027 0.697 0.112 1.368 0.246 2h-1.758c-0.26-0.623-0.426-1.296-0.479-2zm1.002 3h1.497c0.292 0.918 0.693 1.723 1.177 2.365-1.096-0.525-2.022-1.347-2.674-2.365zm4.979 2.936c-1.028-0.275-1.913-1.379-2.45-2.936h2.45v2.936zm0-3.936h-2.731c-0.141-0.623-0.23-1.296-0.259-2h2.99v2zm0-3h-2.99c0.029-0.704 0.118-1.377 0.259-2h2.731v2zm0-3h-2.45c0.537-1.557 1.422-2.661 2.45-2.935v2.935zm5.979 0h-1.496c-0.293-0.918-0.693-1.723-1.178-2.365 1.095 0.525 2.022 1.348 2.674 2.365zm-4.979-2.935c1.027 0.274 1.913 1.378 2.45 2.935h-2.45v-2.935zm0 3.935h2.73c0.142 0.623 0.229 1.296 0.26 2h-2.99v-2zm0 3h2.99c-0.029 0.704-0.118 1.377-0.26 2h-2.73v-2zm0 5.936v-2.936h2.45c-0.537 1.557-1.423 2.661-2.45 2.936zm2.305-0.571c0.483-0.643 0.885-1.447 1.178-2.365h1.496c-0.652 1.018-1.579 1.84-2.674 2.365zm3.197-3.365h-1.758c0.134-0.632 0.219-1.303 0.246-2h1.99c-0.052 0.704-0.218 1.377-0.478 2zm-1.512-3c-0.027-0.697-0.112-1.368-0.246-2h1.758c0.26 0.623 0.426 1.296 0.479 2h-1.991z"/>
</svg>
<svg enable-background="new 0 0 15 15" version="1.1" viewBox="0 0 15 15" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="m14.982 7c-0.246-3.744-3.238-6.737-6.982-6.983v-0.017h-1v0.017c-3.744 0.246-6.737 3.239-6.983 6.983h-0.017v1h0.017c0.246 3.744 3.239 6.736 6.983 6.982v0.018h1v-0.018c3.744-0.246 6.736-3.238 6.982-6.982h0.018v-1h-0.018zm-10.287-5.365c-0.483 0.642-0.884 1.447-1.176 2.365h-1.498c0.652-1.017 1.578-1.84 2.674-2.365zm-3.197 3.365h1.758c-0.134 0.632-0.219 1.303-0.246 2h-1.991c0.053-0.704 0.219-1.377 0.479-2zm-0.479 3h1.991c0.027 0.697 0.112 1.368 0.246 2h-1.758c-0.26-0.623-0.426-1.296-0.479-2zm1.002 3h1.497c0.292 0.918 0.693 1.723 1.177 2.365-1.096-0.525-2.022-1.347-2.674-2.365zm4.979 2.936c-1.028-0.275-1.913-1.379-2.45-2.936h2.45v2.936zm0-3.936h-2.731c-0.141-0.623-0.23-1.296-0.259-2h2.99v2zm0-3h-2.99c0.029-0.704 0.118-1.377 0.259-2h2.731v2zm0-3h-2.45c0.537-1.557 1.422-2.661 2.45-2.935v2.935zm5.979 0h-1.496c-0.293-0.918-0.693-1.723-1.178-2.365 1.095 0.525 2.022 1.348 2.674 2.365zm-4.979-2.935c1.027 0.274 1.913 1.378 2.45 2.935h-2.45v-2.935zm0 3.935h2.73c0.142 0.623 0.229 1.296 0.26 2h-2.99v-2zm0 3h2.99c-0.029 0.704-0.118 1.377-0.26 2h-2.73v-2zm0 5.936v-2.936h2.45c-0.537 1.557-1.423 2.661-2.45 2.936zm2.305-0.571c0.483-0.643 0.885-1.447 1.178-2.365h1.496c-0.652 1.018-1.579 1.84-2.674 2.365zm3.197-3.365h-1.758c0.134-0.632 0.219-1.303 0.246-2h1.99c-0.052 0.704-0.218 1.377-0.478 2zm-1.512-3c-0.027-0.697-0.112-1.368-0.246-2h1.758c0.26 0.623 0.426 1.296 0.479 2h-1.991z"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14.3194 5.30903C14.329 5.44903 14.329 5.58903 14.329 5.73032C14.329 10.0355 11.0516 15.0006 5.05871 15.0006V14.9981C3.28839 15.0006 1.55484 14.4935 0.0645161 13.5374C0.321935 13.5684 0.580645 13.5839 0.84 13.5845C2.3071 13.5858 3.73226 13.0935 4.88645 12.1871C3.49226 12.1606 2.26968 11.2516 1.84258 9.92452C2.33097 10.0187 2.83419 9.99935 3.31355 9.86839C1.79355 9.56129 0.7 8.22581 0.7 6.67484C0.7 6.66064 0.7 6.6471 0.7 6.63355C1.1529 6.88581 1.66 7.02581 2.17871 7.04129C0.747097 6.08451 0.305806 4.18 1.17032 2.69097C2.82452 4.72645 5.26516 5.96387 7.88516 6.09484C7.62258 4.96322 7.98129 3.77742 8.82774 2.98193C10.14 1.74839 12.2039 1.81161 13.4374 3.12322C14.1671 2.97935 14.8665 2.71161 15.5065 2.33226C15.2632 3.08645 14.7542 3.7271 14.0742 4.13419C14.72 4.05806 15.351 3.88516 15.9452 3.62129C15.5077 4.27677 14.9568 4.84774 14.3194 5.30903Z" fill="black"/></svg>

After

Width:  |  Height:  |  Size: 980 B

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

50
core/js/dist/login.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

66
core/js/dist/main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

389
core/js/dist/profile.js vendored Normal file

File diff suppressed because one or more lines are too long

1
core/js/dist/profile.js.map vendored Normal file

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

Some files were not shown because too many files have changed in this diff Show more