From 20d7b39e2dd732c34deef5254cc71f9badaa91df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=B4me=20Chilliet?= Date: Mon, 5 Oct 2026 15:48:44 +0200 Subject: [PATCH 1/3] feat(profile): Add setting for turning profiles private MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This new setting lives in 'settings' application, because the two other profile related settings were already in there. It turns the profile page endpoint into an authenticated page. Signed-off-by: Côme Chilliet --- .../lib/Controller/ProfilePageController.php | 11 +++++ apps/settings/lib/ConfigLexicon.php | 9 ++++ apps/settings/lib/Settings/Admin/Server.php | 2 + .../BasicSettings/ProfileSettings.vue | 41 ++++++++++++++++--- apps/settings/src/service/ProfileService.js | 24 +++++++++++ 5 files changed, 82 insertions(+), 5 deletions(-) diff --git a/apps/profile/lib/Controller/ProfilePageController.php b/apps/profile/lib/Controller/ProfilePageController.php index 69ed0652a4306..3147d0071b399 100644 --- a/apps/profile/lib/Controller/ProfilePageController.php +++ b/apps/profile/lib/Controller/ProfilePageController.php @@ -9,6 +9,8 @@ namespace OCA\Profile\Controller; +use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException; +use OCA\Settings\ConfigLexicon; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\AnonRateLimit; use OCP\AppFramework\Http\Attribute\BruteForceProtection; @@ -20,6 +22,7 @@ use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; use OCP\INavigationManager; use OCP\IRequest; use OCP\IUserManager; @@ -43,6 +46,7 @@ public function __construct( private IUserStatusManager $userStatusManager, private INavigationManager $navigationManager, private IEventDispatcher $eventDispatcher, + private IAppConfig $appConfig, ) { parent::__construct($appName, $request); } @@ -61,6 +65,13 @@ public function index(string $targetUserId): TemplateResponse { TemplateResponse::RENDER_AS_GUEST, ); + if ( + $this->appConfig->getValueBool('settings', ConfigLexicon::PROFILE_PRIVATE) + && !$this->userSession->isLoggedIn() + ) { + throw new NotLoggedInException(); + } + $targetUser = $this->userManager->get($targetUserId); if ($targetUser === null) { $profileNotFoundTemplate->throttle(); diff --git a/apps/settings/lib/ConfigLexicon.php b/apps/settings/lib/ConfigLexicon.php index ca4b6018b001b..c4bcd0a0fd3a6 100644 --- a/apps/settings/lib/ConfigLexicon.php +++ b/apps/settings/lib/ConfigLexicon.php @@ -30,6 +30,8 @@ class ConfigLexicon implements ILexicon { public const USER_LIST_SHOW_NEW_USER_FORM = 'user_list_show_new_user_form'; public const USER_LIST_SHOW_LANGUAGES = 'user_list_show_languages'; + public const PROFILE_PRIVATE = 'profile_private'; + #[\Override] public function getStrictness(): Strictness { return Strictness::IGNORE; @@ -94,6 +96,13 @@ public function getUserConfigs(): array { definition: 'Show languages in user list', lazy: true, ), + new Entry( + key: self::PROFILE_PRIVATE, + type: ValueType::BOOL, + defaultRaw: false, + definition: 'Set profiles as private to the instance', + lazy: false, + ), ]; } } diff --git a/apps/settings/lib/Settings/Admin/Server.php b/apps/settings/lib/Settings/Admin/Server.php index 5f1b8a8bd1a50..b100dd48e824c 100644 --- a/apps/settings/lib/Settings/Admin/Server.php +++ b/apps/settings/lib/Settings/Admin/Server.php @@ -9,6 +9,7 @@ use OC\Profile\ProfileManager; use OC\Profile\TProfileHelper; +use OCA\Settings\ConfigLexicon; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\AppFramework\Utility\ITimeFactory; @@ -56,6 +57,7 @@ public function getForm() { $this->initialStateService->provideInitialState('profileEnabledGlobally', $this->profileManager->isProfileEnabled()); $this->initialStateService->provideInitialState('profileEnabledByDefault', $this->isProfileEnabledByDefault($this->config)); $this->initialStateService->provideInitialState('profilePickerEnabled', $this->isProfilePickerEnabled($this->config)); + $this->initialStateService->provideInitialState('profilePrivate', $this->appConfig->getValueBool('settings', ConfigLexicon::PROFILE_PRIVATE)); // Basic settings $this->initialStateService->provideInitialState('restrictSystemTagsCreationToAdmin', $this->appConfig->getValueBool('systemtags', 'restrict_creation_to_admin', false)); diff --git a/apps/settings/src/components/BasicSettings/ProfileSettings.vue b/apps/settings/src/components/BasicSettings/ProfileSettings.vue index 55d116cbe9573..cec6fa702c07d 100644 --- a/apps/settings/src/components/BasicSettings/ProfileSettings.vue +++ b/apps/settings/src/components/BasicSettings/ProfileSettings.vue @@ -19,9 +19,13 @@ v-model="initialProfileEnabledByDefault" type="switch" @update:modelValue="onProfileDefaultChange"> - {{ t('settings', 'Enable') }} + {{ t('settings', 'Enable by default') }} + + {{ t('settings', 'Enable or disable the profile picker in the Smart Picker and the profile link previews.') }} + + - - {{ t('settings', 'Enable or disable the profile picker in the Smart Picker and the profile link previews.') }} - + + {{ t('settings', 'Prevent unauthenticated users from seeing user profiles') }} + @@ -41,11 +48,12 @@ import { loadState } from '@nextcloud/initial-state' import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import logger from '../../logger.ts' -import { saveProfileDefault, saveProfilePicker } from '../../service/ProfileService.js' +import { saveProfileDefault, saveProfilePicker, saveProfilePrivate } from '../../service/ProfileService.js' import { validateBoolean } from '../../utils/validate.js' const profileEnabledByDefault = loadState('settings', 'profileEnabledByDefault', true) const profilePickerEnabled = loadState('settings', 'profilePickerEnabled', true) +const profilePrivate = loadState('settings', 'profilePrivate', true) export default { name: 'ProfileSettings', @@ -59,6 +67,7 @@ export default { return { initialProfileEnabledByDefault: profileEnabledByDefault, initialProfilePickerEnabled: profilePickerEnabled, + initialProfilePrivate: profilePrivate, } }, @@ -107,6 +116,28 @@ export default { } }, + async onProfilePrivateChange(isEnabled) { + if (validateBoolean(isEnabled)) { + await this.updateProfilePrivate(isEnabled) + } + }, + + async updateProfilePrivate(isEnabled) { + try { + const responseData = await saveProfilePrivate(isEnabled) + this.handleResponse({ + isEnabled, + key: 'initialProfilePrivate', + status: responseData.ocs?.meta?.status, + }) + } catch (e) { + this.handleResponse({ + errorMessage: t('settings', 'Unable to update profile private setting'), + error: e, + }) + } + }, + handleResponse({ isEnabled, key, status, errorMessage, error }) { if (status === 'ok') { this[key] = isEnabled diff --git a/apps/settings/src/service/ProfileService.js b/apps/settings/src/service/ProfileService.js index 1490915c49b50..80f07cdd2e496 100644 --- a/apps/settings/src/service/ProfileService.js +++ b/apps/settings/src/service/ProfileService.js @@ -76,3 +76,27 @@ export async function saveProfilePicker(isEnabled) { return res.data } + +/** + * Save profile private + * + * @param {boolean} isEnabled the default + * @return {object} + */ +export async function saveProfilePrivate(isEnabled) { + // Convert to string for compatibility + isEnabled = isEnabled ? '1' : '0' + + const url = generateOcsUrl('/apps/provisioning_api/api/v1/config/apps/{appId}/{key}', { + appId: 'settings', + key: 'profile_private', + }) + + await confirmPassword() + + const res = await axios.post(url, { + value: isEnabled, + }) + + return res.data +} From bf5f9ed831621d09dc7bccdeeb20233e94dd4345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=B4me=20Chilliet?= Date: Thu, 8 Oct 2026 14:45:32 +0200 Subject: [PATCH 2/3] fix: Use OCP RedirectResponse instead of OC NotLoggedInException MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Côme Chilliet --- .../lib/Controller/ProfilePageController.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/profile/lib/Controller/ProfilePageController.php b/apps/profile/lib/Controller/ProfilePageController.php index 3147d0071b399..1f38a30ace10e 100644 --- a/apps/profile/lib/Controller/ProfilePageController.php +++ b/apps/profile/lib/Controller/ProfilePageController.php @@ -9,7 +9,6 @@ namespace OCA\Profile\Controller; -use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException; use OCA\Settings\ConfigLexicon; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\AnonRateLimit; @@ -19,12 +18,14 @@ use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\EventDispatcher\IEventDispatcher; use OCP\IAppConfig; use OCP\INavigationManager; use OCP\IRequest; +use OCP\IURLGenerator; use OCP\IUserManager; use OCP\IUserSession; use OCP\Profile\BeforeTemplateRenderedEvent; @@ -47,6 +48,7 @@ public function __construct( private INavigationManager $navigationManager, private IEventDispatcher $eventDispatcher, private IAppConfig $appConfig, + private IURLGenerator $urlGenerator, ) { parent::__construct($appName, $request); } @@ -57,7 +59,7 @@ public function __construct( #[BruteForceProtection(action: 'user')] #[UserRateLimit(limit: 30, period: 120)] #[AnonRateLimit(limit: 30, period: 120)] - public function index(string $targetUserId): TemplateResponse { + public function index(string $targetUserId): TemplateResponse|RedirectResponse { $profileNotFoundTemplate = new TemplateResponse( 'profile', '404-profile', @@ -69,7 +71,14 @@ public function index(string $targetUserId): TemplateResponse { $this->appConfig->getValueBool('settings', ConfigLexicon::PROFILE_PRIVATE) && !$this->userSession->isLoggedIn() ) { - throw new NotLoggedInException(); + return new RedirectResponse( + $this->urlGenerator->linkToRoute('core.login.showLoginForm', [ + 'redirect_url' => $this->urlGenerator->linkToRoute( + 'profile.ProfilePage.index', + ['targetUserId' => $targetUserId] + ), + ]) + ); } $targetUser = $this->userManager->get($targetUserId); From a1830e366ecdd97a2855cc4d91d314c8ff1ceaeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=B4me=20Chilliet?= Date: Thu, 8 Oct 2026 15:09:46 +0200 Subject: [PATCH 3/3] chore: Fix profile tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Côme Chilliet --- .../Controller/ProfilePageControllerTest.php | 39 +++---------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/apps/profile/tests/Controller/ProfilePageControllerTest.php b/apps/profile/tests/Controller/ProfilePageControllerTest.php index 2da0d86bddc09..7925fece13c18 100644 --- a/apps/profile/tests/Controller/ProfilePageControllerTest.php +++ b/apps/profile/tests/Controller/ProfilePageControllerTest.php @@ -9,54 +9,25 @@ namespace OCA\Profile\Tests\Controller; -use OC\Profile\ProfileManager; -use OC\UserStatus\Manager; use OCA\Profile\Controller\ProfilePageController; -use OCP\AppFramework\Services\IInitialState; -use OCP\EventDispatcher\IEventDispatcher; -use OCP\INavigationManager; -use OCP\IRequest; use OCP\IUser; use OCP\IUserManager; -use OCP\IUserSession; -use OCP\Share\IManager; -use PHPUnit\Framework\MockObject\MockObject; use Test\TestCase; class ProfilePageControllerTest extends TestCase { - - private IUserManager&MockObject $userManager; private ProfilePageController $controller; protected function setUp(): void { parent::setUp(); - $request = $this->createMock(IRequest::class); - $initialStateService = $this->createMock(IInitialState::class); - $profileManager = $this->createMock(ProfileManager::class); - $shareManager = $this->createMock(IManager::class); - $this->userManager = $this->createMock(IUserManager::class); - $userSession = $this->createMock(IUserSession::class); - $userStatusManager = $this->createMock(Manager::class); - $navigationManager = $this->createMock(INavigationManager::class); - $eventDispatcher = $this->createMock(IEventDispatcher::class); - - $this->controller = new ProfilePageController( - 'profile', - $request, - $initialStateService, - $profileManager, - $shareManager, - $this->userManager, - $userSession, - $userStatusManager, - $navigationManager, - $eventDispatcher, + $this->controller = $this->createInstanceWithMocks( + ProfilePageController::class, + [ 'appName' => 'profile'], ); } public function testUserNotFound(): void { - $this->userManager->method('get') + $this->getAutoMock(IUserManager::class)->method('get') ->willReturn(null); $response = $this->controller->index('bob'); @@ -69,7 +40,7 @@ public function testUserDisabled(): void { $user->method('isEnabled') ->willReturn(false); - $this->userManager->method('get') + $this->getAutoMock(IUserManager::class)->method('get') ->willReturn($user); $response = $this->controller->index('bob');