From c1384c2f6c8e873f6d3f2773f2d3241cae15a11e Mon Sep 17 00:00:00 2001 From: Sagar Gurung <46086950+SagarGi@users.noreply.github.com> Date: Fri, 10 Jan 2025 12:10:11 +0545 Subject: [PATCH] Added authentication method selection in integration setup (#737) * added oidc based authorization in integration Signed-off-by: Sagar * Added change log Signed-off-by: Sagar * review address PR Signed-off-by: Sagar * Added link to user oidc app Signed-off-by: Sagar --------- Signed-off-by: Sagar --- .github/workflows/shared_workflow.yml | 2 + CHANGELOG.md | 1 + lib/Controller/ConfigController.php | 136 ++- lib/Controller/OpenProjectAPIController.php | 120 +-- lib/Dashboard/OpenProjectWidget.php | 26 +- lib/ExchangedTokenRequestedEventHelper.php | 36 + .../LoadAdditionalScriptsListener.php | 28 + lib/Listener/LoadSidebarScript.php | 43 +- lib/Listener/OpenProjectReferenceListener.php | 28 +- lib/Search/OpenProjectSearchProvider.php | 16 +- lib/Service/OpenProjectAPIService.php | 148 ++- lib/Settings/Admin.php | 22 +- lib/Settings/Personal.php | 23 +- psalm.xml | 8 + src/components/AdminSettings.vue | 504 +++++++++- src/components/PersonalSettings.vue | 27 +- src/components/tab/EmptyContent.vue | 16 +- src/utils.js | 20 +- src/views/Dashboard.vue | 39 +- src/views/LinkMultipleFilesModal.vue | 8 +- src/views/ProjectsTab.vue | 10 +- src/views/WorkPackagePickerElement.vue | 20 +- tests/jest/components/AdminSettings.spec.js | 859 +++++++++++++++++- .../jest/components/PersonalSettings.spec.js | 3 +- .../__snapshots__/AdminSettings.spec.js.snap | 20 +- .../jest/components/tab/EmptyContent.spec.js | 3 +- tests/lib/Controller/ConfigControllerTest.php | 574 +++++++++++- .../OpenProjectAPIControllerTest.php | 647 ++++++++++--- .../lib/Service/OpenProjectAPIServiceTest.php | 608 +++++++++++-- tests/lib/Settings/PersonalTest.php | 19 +- 30 files changed, 3586 insertions(+), 428 deletions(-) create mode 100644 lib/ExchangedTokenRequestedEventHelper.php diff --git a/.github/workflows/shared_workflow.yml b/.github/workflows/shared_workflow.yml index f5abd28e..ff855066 100644 --- a/.github/workflows/shared_workflow.yml +++ b/.github/workflows/shared_workflow.yml @@ -116,10 +116,12 @@ jobs: - name: PHP & Vue Unit Tests run: | git clone --depth 1 https://github.com/nextcloud/groupfolders.git -b ${{ matrix.nextcloudVersion }} server/apps/groupfolders + git clone --depth 1 https://github.com/nextcloud/user_oidc.git server/apps/user_oidc mkdir -p server/apps/integration_openproject cp -r `ls -A | grep -v 'server'` server/apps/integration_openproject/ cd server ./occ a:e groupfolders + ./occ a:e user_oidc ./occ a:e integration_openproject cd apps/integration_openproject # The following if block can be removed once Nextcloud no longer supports PHP 8.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b00204..41a7d778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Changed - Add application's support for Nextcloud 31 +- Add support for OIDC-based connection between Nextcloud and OpenProject ## 2.7.2 - 2024-12-16 ### Fixed diff --git a/lib/Controller/ConfigController.php b/lib/Controller/ConfigController.php index 3d298ca6..ba657501 100755 --- a/lib/Controller/ConfigController.php +++ b/lib/Controller/ConfigController.php @@ -142,6 +142,30 @@ class ConfigController extends Controller { $this->config->deleteUserValue($userId, Application::APP_ID, 'refresh_token'); } + /** + * @return void + */ + public function resetOauth2Configs(): void { + // for oauth2 reset we reset openproject client credential as well as nextcloud client credential + $this->config->setAppValue(Application::APP_ID, 'openproject_client_id', ""); + $this->config->setAppValue(Application::APP_ID, 'openproject_client_secret', ""); + $this->deleteOauthClient(); + } + + /** + * @return void + */ + public function resetOIDCConfigs(string $userId = null): void { + if ($userId === null) { + $userId = $this->userId; + } + $this->config->setAppValue(Application::APP_ID, 'oidc_provider', ""); + $this->config->setAppValue(Application::APP_ID, 'targeted_audience_client_id', ""); + $this->config->deleteUserValue($userId, Application::APP_ID, 'user_id'); + $this->config->deleteUserValue($userId, Application::APP_ID, 'user_name'); + } + + /** * set config values * @NoAdminRequired @@ -184,6 +208,9 @@ class ConfigController extends Controller { private function setIntegrationConfig(array $values): array { $allowedKeys = [ 'openproject_instance_url', + 'authorization_method', + 'oidc_provider', + 'targeted_audience_client_id', 'openproject_client_id', 'openproject_client_secret', 'default_enable_navigation', @@ -233,17 +260,72 @@ class ConfigController extends Controller { ); } } - + $oldClientId = $oldClientSecret = ''; $oldOpenProjectOauthUrl = $this->config->getAppValue( Application::APP_ID, 'openproject_instance_url', '' ); - $oldClientId = $this->config->getAppValue( - Application::APP_ID, 'openproject_client_id', '' + $oldAuthMethod = $this->config->getAppValue( + Application::APP_ID, 'authorization_method', '' ); - $oldClientSecret = $this->config->getAppValue( - Application::APP_ID, 'openproject_client_secret', '' + // when 'openproject_instance_url' && 'authorization_method' does not have a value at the same time, + // it means a full reset is done + $runningFullReset = key_exists('openproject_instance_url', $values) && + key_exists('authorization_method', $values) && + !$values['openproject_instance_url'] && + !$values['authorization_method']; + + // determine if the full reset is done when configuration is already with "oauth2" + $runningFullResetWithOAuth2Auth = ( + $runningFullReset && + $oldAuthMethod === OpenProjectAPIService::AUTH_METHOD_OAUTH ); + // determine if the full reset is done when configuration is already with "oidc" + $runningFullResetWithOIDCAuth = ( + $runningFullReset && + $oldAuthMethod === OpenProjectAPIService::AUTH_METHOD_OIDC + ); + if ( + (key_exists('openproject_client_id', $values) && key_exists('openproject_client_secret', $values)) + ) { + $oldClientId = $this->config->getAppValue( + Application::APP_ID, 'openproject_client_id', '' + ); + $oldClientSecret = $this->config->getAppValue( + Application::APP_ID, 'openproject_client_secret', '' + ); + } + // since we can now switch between both authorization method we need to know what we are resetting (either "oauth2" or "oidc" method) + // determines if we are switching from "oauth2" to "oidc" auth method + $runningOauth2Reset = ( + key_exists('openproject_client_id', $values) && + !$values['openproject_client_id'] && + key_exists('openproject_client_secret', $values) && + !$values['openproject_client_secret'] && + $oldOpenProjectOauthUrl && + $oldClientId && + $oldClientSecret + ); + + // determines if we are switching from "oidc" to "oauth2" auth method + $runningOIDCReset = false; + if ( + key_exists('oidc_provider', $values) && + key_exists('targeted_audience_client_id', $values) + ) { + $oldOidcProvider = $this->config->getAppValue( + Application::APP_ID, 'oidc_provider', '' + ); + $oldTargetedAudienceClient = $this->config->getAppValue( + Application::APP_ID, 'targeted_audience_client_id', '' + ); + $runningOIDCReset = ( + $oldOidcProvider && + $oldTargetedAudienceClient && + !$values['oidc_provider'] && + !$values['targeted_audience_client_id']); + } + foreach ($values as $key => $value) { if ($key === 'setup_project_folder' || $key === 'setup_app_password') { continue; @@ -253,7 +335,7 @@ class ConfigController extends Controller { // if the OpenProject OAuth URL has changed if (key_exists('openproject_instance_url', $values) - && $oldOpenProjectOauthUrl !== $values['openproject_instance_url'] + && $oldOpenProjectOauthUrl !== $values['openproject_instance_url'] && (!$runningOIDCReset || !$runningFullResetWithOIDCAuth) ) { // delete the existing OAuth client if new OAuth URL is passed empty if ( @@ -272,22 +354,6 @@ class ConfigController extends Controller { } } - $runningFullReset = ( - - key_exists('openproject_instance_url', $values) && - - key_exists('openproject_client_id', $values) && - - key_exists('openproject_client_secret', $values) && - - $values['openproject_instance_url'] === null && - - $values['openproject_client_id'] === null && - - $values['openproject_client_secret'] === null - - ); - // resetting and keeping the project folder setup should delete the user app password if (key_exists('setup_app_password', $values) && $values['setup_app_password'] === false) { $this->openprojectAPIService->deleteAppPassword(); @@ -301,10 +367,11 @@ class ConfigController extends Controller { $this->config->deleteAppValue(Application::APP_ID, 'oPOAuthTokenRevokeStatus'); if ( // when the OP client information has changed - ((key_exists('openproject_client_id', $values) && $values['openproject_client_id'] !== $oldClientId) || - (key_exists('openproject_client_secret', $values) && $values['openproject_client_secret'] !== $oldClientSecret)) || - // when the OP client information is for reset - $runningFullReset + (!$runningFullResetWithOIDCAuth && ((key_exists('openproject_client_id', $values) && $values['openproject_client_id'] !== $oldClientId) || + (key_exists('openproject_client_secret', $values) && $values['openproject_client_secret'] !== $oldClientSecret))) || + // when the OP client information is reset + $runningFullResetWithOAuth2Auth || + $runningOauth2Reset ) { $this->userManager->callForAllUsers(function (IUser $user) use ( $oldOpenProjectOauthUrl, $oldClientId, $oldClientSecret @@ -346,6 +413,8 @@ class ConfigController extends Controller { } $this->clearUserInfo($userUID); }); + } elseif ($runningFullResetWithOIDCAuth) { + $this->resetOIDCConfigs(); } @@ -362,6 +431,18 @@ class ConfigController extends Controller { $this->config->setAppValue(Application::APP_ID, 'fresh_project_folder_setup', "0"); } + // when switching from "oauth2" to "oidc" authorization method + if (key_exists('authorization_method', $values) && + $values['authorization_method'] === OpenProjectAPIService::AUTH_METHOD_OIDC && $runningOauth2Reset) { + $this->resetOauth2Configs(); + } + + // when switching from "oidc" to "oauth2" authorization method + if (key_exists('authorization_method', $values) && + $values['authorization_method'] === OpenProjectAPIService::AUTH_METHOD_OAUTH && $runningOIDCReset) { + $this->resetOIDCConfigs(); + } + // if the revoke has failed at least once, the last status is stored in the database // this is not a neat way to give proper information about the revoke status // TODO: find way to report every user's revoke status @@ -604,13 +685,12 @@ class ConfigController extends Controller { * @return DataResponse */ public function checkAdminConfigOk(): DataResponse { - $adminConfigStatusWithoutGroupFolderSetupStatus = OpenProjectAPIService::isAdminConfigOk($this->config); $appPasswordSetStatus = $this->openprojectAPIService->hasAppPassword(); // Admin config can be set in two parts // 1. config without project folder set up (which is compulsory for integration) // 2. config with project folder set up (which is optional for admin) return new DataResponse([ - 'config_status_without_project_folder' => $adminConfigStatusWithoutGroupFolderSetupStatus, + 'config_status_without_project_folder' => OpenProjectAPIService::isAdminConfigOk($this->config), 'project_folder_setup_status' => $appPasswordSetStatus ]); } diff --git a/lib/Controller/OpenProjectAPIController.php b/lib/Controller/OpenProjectAPIController.php index 3047d6aa..fbd7d204 100644 --- a/lib/Controller/OpenProjectAPIController.php +++ b/lib/Controller/OpenProjectAPIController.php @@ -87,6 +87,29 @@ class OpenProjectAPIController extends Controller { $this->logger = $logger; } + private function validatePreRequestConditions(): array { + $authMethod = $this->config->getAppValue(Application::APP_ID, 'authorization_method', ''); + if ($authMethod === OpenProjectAPIService::AUTH_METHOD_OAUTH && $this->accessToken === '') { + return [ + 'status' => false, + 'result' => new DataResponse('', Http::STATUS_UNAUTHORIZED) + ]; + } elseif ($authMethod === OpenProjectAPIService::AUTH_METHOD_OIDC && + !$this->openprojectAPIService->getOIDCToken() + ) { + return [ + 'status' => false, + 'result' => new DataResponse('', Http::STATUS_UNAUTHORIZED) + ]; + } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { + return [ + 'status' => false, + 'result' => new DataResponse('', Http::STATUS_BAD_REQUEST) + ]; + } + return ['status' => true, 'result' => null]; + } + /** * get openproject instance URL * @NoAdminRequired @@ -96,7 +119,6 @@ class OpenProjectAPIController extends Controller { public function getOpenProjectUrl(): DataResponse { return new DataResponse($this->openprojectUrl); } - /** * get openproject user avatar * @NoAdminRequired @@ -127,10 +149,9 @@ class OpenProjectAPIController extends Controller { * @return DataResponse */ public function getNotifications(): DataResponse { - if ($this->accessToken === '') { - return new DataResponse('', Http::STATUS_UNAUTHORIZED); - } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { - return new DataResponse('', Http::STATUS_BAD_REQUEST); + $validatePreRequestResult = $this->validatePreRequestConditions(); + if (!$validatePreRequestResult['status']) { + return $validatePreRequestResult['result']; } $result = $this->openprojectAPIService->getNotifications($this->userId); if (!isset($result['error'])) { @@ -161,10 +182,9 @@ class OpenProjectAPIController extends Controller { ?int $fileId = null, bool $isSmartPicker = false ): DataResponse { - if ($this->accessToken === '') { - return new DataResponse('', Http::STATUS_UNAUTHORIZED); - } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { - return new DataResponse('', Http::STATUS_BAD_REQUEST); + $validatePreRequestResult = $this->validatePreRequestConditions(); + if (!$validatePreRequestResult['status']) { + return $validatePreRequestResult['result']; } // when the search is done through smart picker we don't want to check if the work package is linkable $result = $this->openprojectAPIService->searchWorkPackage( @@ -199,12 +219,10 @@ class OpenProjectAPIController extends Controller { * @return DataResponse */ public function linkWorkPackageToFile(array $values): DataResponse { - if ($this->accessToken === '') { - return new DataResponse('', Http::STATUS_UNAUTHORIZED); - } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { - return new DataResponse('', Http::STATUS_BAD_REQUEST); + $validatePreRequestResult = $this->validatePreRequestConditions(); + if (!$validatePreRequestResult['status']) { + return $validatePreRequestResult['result']; } - try { $result = $this->openprojectAPIService->linkWorkPackageToFile( $values, @@ -228,10 +246,9 @@ class OpenProjectAPIController extends Controller { * @return DataResponse */ public function markNotificationAsRead(int $workpackageId) { - if ($this->accessToken === '') { - return new DataResponse('', Http::STATUS_UNAUTHORIZED); - } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { - return new DataResponse('', Http::STATUS_BAD_REQUEST); + $validatePreRequestResult = $this->validatePreRequestConditions(); + if (!$validatePreRequestResult['status']) { + return $validatePreRequestResult['result']; } try { $result = $this->openprojectAPIService->markAllNotificationsOfWorkPackageAsRead( @@ -258,12 +275,10 @@ class OpenProjectAPIController extends Controller { * @return DataResponse */ public function getWorkPackageFileLinks(int $id): DataResponse { - if ($this->accessToken === '') { - return new DataResponse('', Http::STATUS_UNAUTHORIZED); - } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { - return new DataResponse('', Http::STATUS_BAD_REQUEST); + $validatePreRequestResult = $this->validatePreRequestConditions(); + if (!$validatePreRequestResult['status']) { + return $validatePreRequestResult['result']; } - try { $result = $this->openprojectAPIService->getWorkPackageFileLinks( $id, @@ -285,12 +300,10 @@ class OpenProjectAPIController extends Controller { * @return DataResponse */ public function deleteFileLink(int $id): DataResponse { - if ($this->accessToken === '') { - return new DataResponse('', Http::STATUS_UNAUTHORIZED); - } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { - return new DataResponse('', Http::STATUS_BAD_REQUEST); + $validatePreRequestResult = $this->validatePreRequestConditions(); + if (!$validatePreRequestResult['status']) { + return $validatePreRequestResult['result']; } - try { $result = $this->openprojectAPIService->deleteFileLink( $id, @@ -316,12 +329,10 @@ class OpenProjectAPIController extends Controller { * @return DataResponse */ public function getOpenProjectWorkPackageStatus(string $id): DataResponse { - if ($this->accessToken === '') { - return new DataResponse('', Http::STATUS_UNAUTHORIZED); - } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { - return new DataResponse('', Http::STATUS_BAD_REQUEST); + $validatePreRequestResult = $this->validatePreRequestConditions(); + if (!$validatePreRequestResult['status']) { + return $validatePreRequestResult['result']; } - $result = $this->openprojectAPIService->getOpenProjectWorkPackageStatus( $this->userId, $id ); @@ -343,12 +354,10 @@ class OpenProjectAPIController extends Controller { * @return DataResponse */ public function getOpenProjectWorkPackageType(string $id): DataResponse { - if ($this->accessToken === '') { - return new DataResponse('', Http::STATUS_UNAUTHORIZED); - } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { - return new DataResponse('', Http::STATUS_BAD_REQUEST); + $validatePreRequestResult = $this->validatePreRequestConditions(); + if (!$validatePreRequestResult['status']) { + return $validatePreRequestResult['result']; } - $result = $this->openprojectAPIService->getOpenProjectWorkPackageType( $this->userId, $id ); @@ -366,10 +375,9 @@ class OpenProjectAPIController extends Controller { * @return DataResponse */ public function getAvailableOpenProjectProjects(?string $searchQuery = null): DataResponse { - if ($this->accessToken === '') { - return new DataResponse('', Http::STATUS_UNAUTHORIZED); - } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { - return new DataResponse('', Http::STATUS_BAD_REQUEST); + $validatePreRequestResult = $this->validatePreRequestConditions(); + if (!$validatePreRequestResult['status']) { + return $validatePreRequestResult['result']; } try { $result = $this->openprojectAPIService->getAvailableOpenProjectProjects($this->userId, $searchQuery); @@ -418,10 +426,9 @@ class OpenProjectAPIController extends Controller { * @return DataResponse */ public function getOpenProjectWorkPackageForm(string $projectId, array $body): DataResponse { - if ($this->accessToken === '') { - return new DataResponse('', Http::STATUS_UNAUTHORIZED); - } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { - return new DataResponse('', Http::STATUS_BAD_REQUEST); + $validatePreRequestResult = $this->validatePreRequestConditions(); + if (!$validatePreRequestResult['status']) { + return $validatePreRequestResult['result']; } try { $result = $this->openprojectAPIService->getOpenProjectWorkPackageForm($this->userId, $projectId, $body); @@ -440,10 +447,9 @@ class OpenProjectAPIController extends Controller { * @return DataResponse */ public function getAvailableAssigneesOfAProject(string $projectId): DataResponse { - if ($this->accessToken === '') { - return new DataResponse('', Http::STATUS_UNAUTHORIZED); - } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { - return new DataResponse('', Http::STATUS_BAD_REQUEST); + $validatePreRequestResult = $this->validatePreRequestConditions(); + if (!$validatePreRequestResult['status']) { + return $validatePreRequestResult['result']; } try { $result = $this->openprojectAPIService->getAvailableAssigneesOfAProject($this->userId, $projectId); @@ -489,10 +495,9 @@ class OpenProjectAPIController extends Controller { * @return DataResponse */ public function createWorkPackage(array $body): DataResponse { - if ($this->accessToken === '') { - return new DataResponse('', Http::STATUS_UNAUTHORIZED); - } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { - return new DataResponse('', Http::STATUS_BAD_REQUEST); + $validatePreRequestResult = $this->validatePreRequestConditions(); + if (!$validatePreRequestResult['status']) { + return $validatePreRequestResult['result']; } // we don't want to check if all the data in the body is set or not because // that calculation will be done by the openproject api itself @@ -518,10 +523,9 @@ class OpenProjectAPIController extends Controller { * @return DataResponse */ public function getOpenProjectConfiguration(): DataResponse { - if ($this->accessToken === '') { - return new DataResponse('', Http::STATUS_UNAUTHORIZED); - } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { - return new DataResponse('', Http::STATUS_BAD_REQUEST); + $validatePreRequestResult = $this->validatePreRequestConditions(); + if (!$validatePreRequestResult['status']) { + return $validatePreRequestResult['result']; } try { $result = $this->openprojectAPIService->getOpenProjectConfiguration($this->userId); diff --git a/lib/Dashboard/OpenProjectWidget.php b/lib/Dashboard/OpenProjectWidget.php index d179bd93..7f1dea44 100644 --- a/lib/Dashboard/OpenProjectWidget.php +++ b/lib/Dashboard/OpenProjectWidget.php @@ -60,18 +60,26 @@ class OpenProjectWidget implements IWidget { */ private $user; + /** + * @var OpenProjectAPIService + */ + private OpenProjectAPIService $openProjectAPIService; + + public function __construct( IL10N $l10n, IInitialState $initialStateService, IURLGenerator $url, IConfig $config, - IUserSession $userSession + IUserSession $userSession, + OpenProjectAPIService $openProjectAPIService ) { $this->initialStateService = $initialStateService; $this->l10n = $l10n; $this->url = $url; $this->config = $config; $this->user = $userSession->getUser(); + $this->openProjectAPIService = $openProjectAPIService; } /** @@ -115,15 +123,25 @@ class OpenProjectWidget implements IWidget { public function load(): void { Util::addScript(Application::APP_ID, Application::APP_ID . '-dashboard'); Util::addStyle(Application::APP_ID, 'dashboard'); - - $this->initialStateService->provideInitialState('admin-config-status', OpenProjectAPIService::isAdminConfigOk($this->config)); - $oauthConnectionResult = $this->config->getUserValue( $this->user->getUID(), Application::APP_ID, 'oauth_connection_result', '' ); $this->config->deleteUserValue( $this->user->getUID(), Application::APP_ID, 'oauth_connection_result' ); + + $authorizationMethod = $this->config->getAppValue(Application::APP_ID, 'authorization_method', ''); + $this->initialStateService->provideInitialState('authorization_method', $authorizationMethod); + $this->initialStateService->provideInitialState( + 'admin_config_ok', OpenProjectAPIService::isAdminConfigOk($this->config) + ); + + // authorization method can be either a 'oidc' or 'oauth2' + // for 'oidc' state to be loaded + $token = $this->openProjectAPIService->getOIDCToken(); + $this->initialStateService->provideInitialState('user-has-oidc-token', $token !== null); + + // for 'oauth2' state to be loaded $this->initialStateService->provideInitialState( 'oauth-connection-result', $oauthConnectionResult ); diff --git a/lib/ExchangedTokenRequestedEventHelper.php b/lib/ExchangedTokenRequestedEventHelper.php new file mode 100644 index 00000000..564d1996 --- /dev/null +++ b/lib/ExchangedTokenRequestedEventHelper.php @@ -0,0 +1,36 @@ +config = $config; + } + + /** + * @return ExchangedTokenRequestedEvent + */ + public function getEvent(): ExchangedTokenRequestedEvent { + return new ExchangedTokenRequestedEvent( + $this->config->getAppValue(Application::APP_ID, 'targeted_audience_client_id', '') + ); + } +} diff --git a/lib/Listener/LoadAdditionalScriptsListener.php b/lib/Listener/LoadAdditionalScriptsListener.php index d8c90d3f..30f27387 100644 --- a/lib/Listener/LoadAdditionalScriptsListener.php +++ b/lib/Listener/LoadAdditionalScriptsListener.php @@ -5,8 +5,10 @@ namespace OCA\OpenProject\Listener; use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCA\OpenProject\AppInfo\Application; use OCA\OpenProject\ServerVersionHelper; +use OCA\OpenProject\Service\OpenProjectAPIService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; +use OCP\IConfig; use OCP\Util; /** @@ -14,7 +16,33 @@ use OCP\Util; */ class LoadAdditionalScriptsListener implements IEventListener { + /** + * @var OpenProjectAPIService + */ + private $openProjectAPIService; + /** + * @var IConfig + */ + private $config; + + public function __construct( + IConfig $config, + OpenProjectAPIService $openProjectAPIService, + ) { + $this->config = $config; + $this->openProjectAPIService = $openProjectAPIService; + } + public function handle(Event $event): void { + // When user is non oidc based or there is some error when getting token for the targeted client + // then we need to hide the oidc based connection for the user + // so this check is required + if ( + $this->config->getAppValue(Application::APP_ID, 'authorization_method', '') === OpenProjectAPIService::AUTH_METHOD_OIDC && + !$this->openProjectAPIService->getOIDCToken() + ) { + return; + } if (!$event instanceof LoadAdditionalScriptsEvent) { return; } diff --git a/lib/Listener/LoadSidebarScript.php b/lib/Listener/LoadSidebarScript.php index 63a89307..50c2223a 100644 --- a/lib/Listener/LoadSidebarScript.php +++ b/lib/Listener/LoadSidebarScript.php @@ -66,16 +66,30 @@ class LoadSidebarScript implements IEventListener { */ protected $appManager; + /** + * @var OpenProjectAPIService + */ + private $openProjectAPIService; + private IUserSession $userSession; + /** + * @var string|null + */ + private $userId; + public function __construct( IInitialState $initialStateService, IConfig $config, IUserSession $userSession, - IAppManager $appManager + IAppManager $appManager, + OpenProjectAPIService $openProjectAPIService, + ?string $userId ) { $this->initialStateService = $initialStateService; $this->config = $config; $this->appManager = $appManager; + $this->userId = $userId; $user = $userSession->getUser(); + $this->openProjectAPIService = $openProjectAPIService; if (strpos(\OC::$server->get(IRequest::class)->getRequestUri(), 'files') !== false) { $this->oauthConnectionResult = $this->config->getUserValue( $user->getUID(), Application::APP_ID, 'oauth_connection_result', '' @@ -93,6 +107,15 @@ class LoadSidebarScript implements IEventListener { } public function handle(Event $event): void { + // When user is non oidc based or there is some error when getting token for the targeted client + // then we need to hide the oidc based connection for the user + // so this check is required + if ( + $this->config->getAppValue(Application::APP_ID, 'authorization_method', '') === OpenProjectAPIService::AUTH_METHOD_OIDC && + !$this->openProjectAPIService->getOIDCToken() + ) { + return; + } if (!($event instanceof LoadSidebar)) { return; } @@ -108,17 +131,25 @@ class LoadSidebarScript implements IEventListener { } Util::addStyle(Application::APP_ID, 'tab'); - $this->initialStateService->provideInitialState('admin-config-status', OpenProjectAPIService::isAdminConfigOk($this->config)); + $authorizationMethod = $this->config->getAppValue(Application::APP_ID, 'authorization_method', ''); + $this->initialStateService->provideInitialState('authorization_method', $authorizationMethod); + $this->initialStateService->provideInitialState( + 'openproject-url', $this->config->getAppValue(Application::APP_ID, 'openproject_instance_url') + ); + $this->initialStateService->provideInitialState( + 'admin_config_ok', OpenProjectAPIService::isAdminConfigOk($this->config) + ); + // authorization method can be either a 'oidc' or 'oauth2' + // for 'oidc' the user info needs to be set (once token has been exchanged) + $this->openProjectAPIService->setUserInfoForOidcBasedAuth($this->userId); + + // for 'oauth2' state to be loaded $this->initialStateService->provideInitialState( 'oauth-connection-result', $this->oauthConnectionResult ); $this->initialStateService->provideInitialState( 'oauth-connection-error-message', $this->oauthConnectionErrorMessage ); - $this->initialStateService->provideInitialState( - 'openproject-url', - $this->config->getAppValue(Application::APP_ID, 'openproject_instance_url') - ); } } diff --git a/lib/Listener/OpenProjectReferenceListener.php b/lib/Listener/OpenProjectReferenceListener.php index cf7ca847..c0518d62 100644 --- a/lib/Listener/OpenProjectReferenceListener.php +++ b/lib/Listener/OpenProjectReferenceListener.php @@ -46,22 +46,42 @@ class OpenProjectReferenceListener implements IEventListener { * @var IConfig */ private $config; + /** + * @var OpenProjectAPIService + */ + private $openProjectAPIService; public function __construct( IInitialState $initialStateService, - IConfig $config + IConfig $config, + OpenProjectAPIService $openProjectAPIService, ) { $this->initialStateService = $initialStateService; $this->config = $config; + $this->openProjectAPIService = $openProjectAPIService; } public function handle(Event $event): void { + // When user is non oidc based or there is some error when getting token for the targeted client + // then we need to hide the oidc based connection for the user + // so this check is required + if ( + $this->config->getAppValue(Application::APP_ID, 'authorization_method', '') === OpenProjectAPIService::AUTH_METHOD_OIDC && + !$this->openProjectAPIService->getOIDCToken() + ) { + return; + } if (!$event instanceof RenderReferenceEvent) { return; } - Util::addScript(Application::APP_ID, Application::APP_ID . '-reference'); - $this->initialStateService->provideInitialState('admin-config-status', OpenProjectAPIService::isAdminConfigOk($this->config)); - + $adminConfig = [ + 'isAdminConfigOk' => OpenProjectAPIService::isAdminConfigOk($this->config), + 'authMethod' => $this->config->getAppValue(Application::APP_ID, 'authorization_method', '') + ]; + $this->initialStateService->provideInitialState( + 'admin-config', + $adminConfig + ); $this->initialStateService->provideInitialState( 'openproject-url', $this->config->getAppValue(Application::APP_ID, 'openproject_instance_url') diff --git a/lib/Search/OpenProjectSearchProvider.php b/lib/Search/OpenProjectSearchProvider.php index 6d33fdf2..d43c6c99 100644 --- a/lib/Search/OpenProjectSearchProvider.php +++ b/lib/Search/OpenProjectSearchProvider.php @@ -109,13 +109,23 @@ class OpenProjectSearchProvider implements IProvider { $offset = $query->getCursor(); $offset = $offset ? intval($offset) : 0; $openprojectUrl = OpenProjectAPIService::sanitizeUrl($this->config->getAppValue(Application::APP_ID, 'openproject_instance_url')); - $accessToken = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'token'); + + $authorizationMethod = $this->config->getAppValue(Application::APP_ID, 'authorization_method', ''); $searchEnabled = $this->config->getUserValue( $user->getUID(), Application::APP_ID, 'search_enabled', $this->config->getAppValue(Application::APP_ID, 'default_enable_unified_search', '0')) === '1'; - if ($accessToken === '' || !$searchEnabled) { - return SearchResult::paginated($this->getName(), [], 0); + + if ($authorizationMethod === OpenProjectAPIService::AUTH_METHOD_OAUTH) { + $accessToken = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'token'); + if (!$accessToken || !$searchEnabled) { + return SearchResult::paginated($this->getName(), [], 0); + } + } elseif ($authorizationMethod === OpenProjectAPIService::AUTH_METHOD_OIDC) { + $accessToken = $this->service->getOIDCToken(); + if (!$accessToken || !$searchEnabled) { + return SearchResult::paginated($this->getName(), [], 0); + } } $searchResults = $this->service->searchWorkPackage($user->getUID(), $term, null, false); diff --git a/lib/Service/OpenProjectAPIService.php b/lib/Service/OpenProjectAPIService.php index 213c1f6c..8fc803f5 100644 --- a/lib/Service/OpenProjectAPIService.php +++ b/lib/Service/OpenProjectAPIService.php @@ -29,9 +29,12 @@ use OCA\OpenProject\Exception\OpenprojectAvatarErrorException; use OCA\OpenProject\Exception\OpenprojectErrorException; use OCA\OpenProject\Exception\OpenprojectGroupfolderSetupConflictException; use OCA\OpenProject\Exception\OpenprojectResponseException; +use OCA\OpenProject\ExchangedTokenRequestedEventHelper; use OCA\TermsOfService\Db\Entities\Signatory; use OCA\TermsOfService\Db\Mapper\SignatoryMapper; use OCA\TermsOfService\Db\Mapper\TermsMapper; +use OCA\UserOIDC\Db\ProviderMapper; +use OCA\UserOIDC\Exception\TokenExchangeFailedException; use OCP\App\IAppManager; use OCP\AppFramework\Http; use OCP\Encryption\IManager; @@ -62,6 +65,8 @@ use Psr\Log\LoggerInterface; define('CACHE_TTL', 3600); class OpenProjectAPIService { + public const AUTH_METHOD_OAUTH = 'oauth2'; + public const AUTH_METHOD_OIDC = 'oidc'; /** * @var string */ @@ -133,6 +138,7 @@ class OpenProjectAPIService { private ISecureRandom $random; private IEventDispatcher $eventDispatcher; private AuditLogger $auditLogger; + private ExchangedTokenRequestedEventHelper $exchangedTokenRequestedEventHelper; public function __construct( string $appName, @@ -153,7 +159,8 @@ class OpenProjectAPIService { ISubAdmin $subAdminManager, IDBConnection $db, ILogFactory $logFactory, - IManager $encryptionManager + IManager $encryptionManager, + ExchangedTokenRequestedEventHelper $exchangedTokenRequestedEventHelper, ) { $this->appName = $appName; $this->avatarManager = $avatarManager; @@ -174,6 +181,7 @@ class OpenProjectAPIService { $this->db = $db; $this->logFactory = $logFactory; $this->encryptionManager = $encryptionManager; + $this->exchangedTokenRequestedEventHelper = $exchangedTokenRequestedEventHelper; } /** @@ -316,9 +324,13 @@ class OpenProjectAPIService { string $openprojectUserName, string $nextcloudUserId ): array { - $accessToken = $this->config->getUserValue($nextcloudUserId, Application::APP_ID, 'token'); - $this->config->getAppValue(Application::APP_ID, 'openproject_client_id'); - $this->config->getAppValue(Application::APP_ID, 'openproject_client_secret'); + if ($this->config->getAppValue(Application::APP_ID, 'authorization_method', '') === self::AUTH_METHOD_OIDC) { + $accessToken = $this->getOIDCToken(); + } else { + $accessToken = $this->config->getUserValue($nextcloudUserId, Application::APP_ID, 'token'); + $this->config->getAppValue(Application::APP_ID, 'openproject_client_id'); + $this->config->getAppValue(Application::APP_ID, 'openproject_client_secret'); + } $openprojectUrl = $this->config->getAppValue(Application::APP_ID, 'openproject_instance_url'); try { $response = $this->rawRequest( @@ -439,10 +451,14 @@ class OpenProjectAPIService { */ public function request(string $userId, string $endPoint, array $params = [], string $method = 'GET'): array { - $accessToken = $this->config->getUserValue($userId, Application::APP_ID, 'token'); - $refreshToken = $this->config->getUserValue($userId, Application::APP_ID, 'refresh_token'); - $clientID = $this->config->getAppValue(Application::APP_ID, 'openproject_client_id'); - $clientSecret = $this->config->getAppValue(Application::APP_ID, 'openproject_client_secret'); + if ($this->config->getAppValue(Application::APP_ID, 'authorization_method', '') === self::AUTH_METHOD_OIDC) { + $accessToken = $this->getOIDCToken(); + } else { + $accessToken = $this->config->getUserValue($userId, Application::APP_ID, 'token'); + $refreshToken = $this->config->getUserValue($userId, Application::APP_ID, 'refresh_token'); + $clientID = $this->config->getAppValue(Application::APP_ID, 'openproject_client_id'); + $clientSecret = $this->config->getAppValue(Application::APP_ID, 'openproject_client_secret'); + } $openprojectUrl = $this->config->getAppValue(Application::APP_ID, 'openproject_instance_url'); if (!$openprojectUrl || !OpenProjectAPIService::validateURL($openprojectUrl)) { return ['error' => 'OpenProject URL is invalid', 'statusCode' => 500]; @@ -460,7 +476,11 @@ class OpenProjectAPIService { $body = (string) $response->getBody(); // refresh token if it's invalid and we are using oauth // response can be : 'OAuth2 token is expired!', 'Invalid token!' or 'Not authorized' - if ($response->getStatusCode() === 401) { + // This condition applies exclusively to the OAuth2 authorization method and not to OIDC authorization, + // as token refreshing for OIDC is managed by the 'user_oidc' application. + if ($response->getStatusCode() === 401 && + $this->config->getAppValue(Application::APP_ID, 'authorization_method', '') === self::AUTH_METHOD_OAUTH + ) { $this->logger->info('Trying to REFRESH the access token', ['app' => $this->appName]); // try to refresh the token $result = $this->requestOAuthAccessToken($openprojectUrl, [ @@ -912,13 +932,13 @@ class OpenProjectAPIService { } /** - * checks if every admin config variables are set + * checks if every admin config for oauth2 based authorization variables are set * checks if the oauth instance url is valid * * @param IConfig $config * @return bool */ - public static function isAdminConfigOk(IConfig $config):bool { + public static function isAdminConfigOkForOauth2(IConfig $config):bool { $clientId = $config->getAppValue(Application::APP_ID, 'openproject_client_id'); $clientSecret = $config->getAppValue(Application::APP_ID, 'openproject_client_secret'); $oauthInstanceUrl = $config->getAppValue(Application::APP_ID, 'openproject_instance_url'); @@ -930,6 +950,41 @@ class OpenProjectAPIService { } } + /** + * checks if every admin config for oidc based authorization variables are set + * checks if the oauth instance url is valid + * + * @param IConfig $config + * @return bool + */ + public static function isAdminConfigOkForOIDCAuth(IConfig $config):bool { + $oidcProvider = $config->getAppValue(Application::APP_ID, 'oidc_provider'); + $targetAudienceClientId = $config->getAppValue(Application::APP_ID, 'targeted_audience_client_id'); + $oauthInstanceUrl = $config->getAppValue(Application::APP_ID, 'openproject_instance_url'); + $checkIfConfigIsSet = !!($oidcProvider) && !!($targetAudienceClientId) && !!($oauthInstanceUrl); + if (!$checkIfConfigIsSet) { + return false; + } else { + return self::validateURL($oauthInstanceUrl); + } + } + + + /** + * returns overall admin config status whether it be 'oidc' or 'oauth2' + * + * @return bool + */ + public static function isAdminConfigOk(IConfig $config): bool { + $authMethod = $config->getAppValue(Application::APP_ID, 'authorization_method'); + if ($authMethod === self::AUTH_METHOD_OAUTH) { + return self::isAdminConfigOkForOauth2($config); + } elseif ($authMethod === self::AUTH_METHOD_OIDC) { + return self::isAdminConfigOkForOIDCAuth($config); + } + return false; + } + /** * makes sure the URL has no extra slashes */ @@ -1339,7 +1394,11 @@ class OpenProjectAPIService { * @return array|null */ public function getWorkPackageInfo(string $userId, int $wpId): ?array { - $accessToken = $this->config->getUserValue($userId, Application::APP_ID, 'token'); + if ($this->config->getAppValue(Application::APP_ID, 'authorization_method', '') === self::AUTH_METHOD_OIDC) { + $accessToken = $this->getOIDCToken(); + } else { + $accessToken = $this->config->getUserValue($userId, Application::APP_ID, 'token'); + } if ($accessToken) { $searchResult = $this->searchWorkPackage($userId, null, null, false, $wpId); if (isset($searchResult['error'])) { @@ -1558,4 +1617,69 @@ class OpenProjectAPIService { } return $result; } + + + /** + * @return string|null + */ + public function getOIDCToken(): ?string { + if (!$this->isUserOIDCAppInstalledAndEnabled()) { + $this->logger->debug('The user_oidc app is not installed or enabled'); + return null; + } + try { + $event = $this->exchangedTokenRequestedEventHelper->getEvent(); + /** @psalm-suppress InvalidArgument for dispatchTyped($event) + * but new ExchangedTokenRequestedEvent(targeted_audience_client_id) returns event + */ + $this->eventDispatcher->dispatchTyped($event); + } catch (TokenExchangeFailedException $e) { + $this->logger->debug('Failed to exchange token: ' . $e->getMessage()); + return null; + } + $token = $event->getToken(); + if ($token === null) { + $this->logger->debug('ExchangedTokenRequestedEvent event has not been caught by user_oidc'); + return null; + } + // token expiration info + $this->logger->debug('Obtained a token that expires in ' . $token->getExpiresInFromNow()); + return $token->getAccessToken(); + } + + + /** + * @param string $userId + * @return void + */ + public function setUserInfoForOidcBasedAuth(string $userId): void { + $info = $this->request($userId, 'users/me'); + if (isset($info['lastName'], $info['firstName'], $info['id'])) { + $fullName = $info['firstName'] . ' ' . $info['lastName']; + $this->config->setUserValue($userId, Application::APP_ID, 'user_id', $info['id']); + $this->config->setUserValue($userId, Application::APP_ID, 'user_name', $fullName); + } + } + + public function getRegisteredOidcProviders(): array { + $oidcProviders = []; + if ($this->isUserOIDCAppInstalledAndEnabled()) { + $providerMapper = new ProviderMapper($this->db); + foreach ($providerMapper->getProviders() as $provider) { + $oidcProviders[] = $provider->getIdentifier(); + } + } + return $oidcProviders; + } + + public function isUserOIDCAppInstalledAndEnabled(): bool { + return ( + class_exists('\OCA\UserOIDC\Db\ProviderMapper') && + class_exists('\OCA\UserOIDC\Event\ExchangedTokenRequestedEvent') && + class_exists('\OCA\UserOIDC\Exception\TokenExchangeFailedException') && + $this->appManager->isInstalled( + 'user_oidc', + ) + ); + } } diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index ed488fb0..3cb315ae 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -34,7 +34,8 @@ class Admin implements ISettings { public function __construct(IConfig $config, OauthService $oauthService, OpenProjectAPIService $openProjectAPIService, - IInitialState $initialStateService) { + IInitialState $initialStateService + ) { $this->config = $config; $this->initialStateService = $initialStateService; $this->oauthService = $oauthService; @@ -59,10 +60,18 @@ class Admin implements ISettings { $projectFolderStatusInformation = $this->openProjectAPIService->getProjectFolderSetupInformation(); $isAllTermsOfServiceSignedForUserOpenProject = $this->openProjectAPIService->isAllTermsOfServiceSignedForUserOpenProject(); $isAdminAuditConfigurationSetUpCorrectly = $this->openProjectAPIService->isAdminAuditConfigSetCorrectly(); + $adminConfig = [ 'openproject_client_id' => $clientID, 'openproject_client_secret' => $clientSecret, 'openproject_instance_url' => $oauthUrl, + 'authorization_method' => $this->config->getAppValue(Application::APP_ID, 'authorization_method', ''), + 'authorization_settings' => [ + 'oidc_provider' => $this->config->getAppValue(Application::APP_ID, 'oidc_provider', ''), + 'targeted_audience_client_id' => $this->config->getAppValue( + Application::APP_ID, 'targeted_audience_client_id', '' + ), + ], 'nc_oauth_client' => $clientInfo, 'default_enable_navigation' => $this->config->getAppValue(Application::APP_ID, 'default_enable_navigation', '0') === '1', 'default_enable_unified_search' => $this->config->getAppValue(Application::APP_ID, 'default_enable_unified_search', '0') === '1', @@ -74,13 +83,16 @@ class Admin implements ISettings { 'encryption_info' => [ 'server_side_encryption_enabled' => $this->openProjectAPIService->isServerSideEncryptionEnabled(), 'encryption_enabled_for_groupfolders' => $this->config->getAppValue('groupfolders', 'enable_encryption', '') === 'true' - ] + ], + 'oidc_provider' => $this->openProjectAPIService->getRegisteredOidcProviders(), + 'user_oidc_enabled' => $this->openProjectAPIService->isUserOIDCAppInstalledAndEnabled() ]; - $adminConfigStatus = OpenProjectAPIService::isAdminConfigOk($this->config); - $this->initialStateService->provideInitialState('admin-config', $adminConfig); - $this->initialStateService->provideInitialState('admin-config-status', $adminConfigStatus); + $this->initialStateService->provideInitialState( + 'admin-config-status', OpenProjectAPIService::isAdminConfigOk($this->config) + ); + return new TemplateResponse(Application::APP_ID, 'adminSettings'); } diff --git a/lib/Settings/Personal.php b/lib/Settings/Personal.php index 10590df3..5835cdfa 100644 --- a/lib/Settings/Personal.php +++ b/lib/Settings/Personal.php @@ -25,24 +25,40 @@ class Personal implements ISettings { */ private $userId; + /** + * @var OpenProjectAPIService + */ + private $openProjectAPIService; + public function __construct( IConfig $config, IInitialState $initialStateService, + OpenProjectAPIService $openProjectAPIService, ?string $userId) { $this->config = $config; $this->initialStateService = $initialStateService; $this->userId = $userId; + $this->openProjectAPIService = $openProjectAPIService; } /** * @return TemplateResponse */ public function getForm(): TemplateResponse { - $token = $this->config->getUserValue($this->userId, Application::APP_ID, 'token'); + $authorizationMethod = $this->config->getAppValue(Application::APP_ID, 'authorization_method', ''); + $token = ''; + if ($authorizationMethod === OpenProjectAPIService::AUTH_METHOD_OIDC) { + $token = $this->openProjectAPIService->getOIDCToken(); + if ($token) { + // when connection is oidc based then user information needs to be saved + $this->openProjectAPIService->setUserInfoForOidcBasedAuth($this->userId); + } + } + if ($authorizationMethod === OpenProjectAPIService::AUTH_METHOD_OAUTH) { + $token = $this->config->getUserValue($this->userId, Application::APP_ID, 'token'); + } $userName = $this->config->getUserValue($this->userId, Application::APP_ID, 'user_name'); - - // take the fallback value from the defaults $searchEnabled = $this->config->getUserValue( $this->userId, @@ -65,6 +81,7 @@ class Personal implements ISettings { ]; $userConfig['admin_config_ok'] = OpenProjectAPIService::isAdminConfigOk($this->config); + $userConfig['authorization_method'] = $authorizationMethod; $this->initialStateService->provideInitialState('user-config', $userConfig); $oauthConnectionResult = $this->config->getUserValue( diff --git a/psalm.xml b/psalm.xml index 13d3b290..d188b313 100644 --- a/psalm.xml +++ b/psalm.xml @@ -31,6 +31,11 @@ + + + + + @@ -83,6 +88,9 @@ + + + diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index bd43c345..fd231522 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -57,13 +57,153 @@ -
+
+
+
+
+

+ {{ t('integration_openproject', 'Need help setting this up?') }} +

+

+

+
+ + {{ authMethodsLabel.OAUTH2 }} + + + {{ authMethodsLabel.OIDC }} + +

+

+
+
+

+ {{ getSelectedAuthenticatedMethod }} +

+
+
+ + + {{ t('integration_openproject', 'Edit authorization method') }} + + + {{ t('integration_openproject', 'Cancel') }} + + + + {{ t('integration_openproject', 'Save') }} + +
+
+
+
+ +
+ +
+ +
+ +
+

+

+ +
+ +
+
+
+ + + {{ t('integration_openproject', 'Edit athorization settings') }} + + + {{ t('integration_openproject', 'Cancel') }} + + + + {{ t('integration_openproject', 'Save') }} + +
+
+
+ -
+
-
- +
-
- {{ t('integration_openproject', 'Reset') }} -
+

{{ t('integration_openproject', 'Default user settings') }}

{{ t('integration_openproject', 'A new user will receive these defaults and they will be applied to the integration app till the user changes them.') }} @@ -372,7 +512,13 @@ import { generateUrl } from '@nextcloud/router' import { showSuccess, showError } from '@nextcloud/dialogs' import CheckBoldIcon from 'vue-material-design-icons/CheckBold.vue' import PencilIcon from 'vue-material-design-icons/Pencil.vue' -import { NcLoadingIcon, NcCheckboxRadioSwitch, NcButton, NcNoteCard } from '@nextcloud/vue' +import { + NcLoadingIcon, + NcCheckboxRadioSwitch, + NcButton, + NcNoteCard, + NcSelect, +} from '@nextcloud/vue' import RestoreIcon from 'vue-material-design-icons/Restore.vue' import AutoRenewIcon from 'vue-material-design-icons/Autorenew.vue' import TextInput from './admin/TextInput.vue' @@ -380,12 +526,13 @@ import FieldValue from './admin/FieldValue.vue' import FormHeading from './admin/FormHeading.vue' import CheckBox from '../components/settings/CheckBox.vue' import SettingsTitle from '../components/settings/SettingsTitle.vue' -import { F_MODES, FORM, USER_SETTINGS } from '../utils.js' +import { F_MODES, FORM, USER_SETTINGS, AUTH_METHOD, AUTH_METHOD_LABEL } from '../utils.js' import TermsOfServiceUnsigned from './admin/TermsOfServiceUnsigned.vue' import dompurify from 'dompurify' export default { name: 'AdminSettings', components: { + NcSelect, NcButton, FieldValue, FormHeading, @@ -407,13 +554,15 @@ export default { // server host form is never disabled. // it's either editable or view only server: F_MODES.EDIT, + authorizationMethod: F_MODES.DISABLE, + authorizationSetting: F_MODES.DISABLE, opOauth: F_MODES.DISABLE, ncOauth: F_MODES.DISABLE, opUserAppPassword: F_MODES.DISABLE, projectFolderSetUp: F_MODES.DISABLE, }, isFormCompleted: { - server: false, opOauth: false, ncOauth: false, opUserAppPassword: false, projectFolderSetUp: false, + server: false, authorizationMethod: false, authorizationSetting: false, opOauth: false, ncOauth: false, opUserAppPassword: false, projectFolderSetUp: false, }, buttonTextLabel: { keepCurrentChange: t('integration_openproject', 'Keep current setup'), @@ -423,6 +572,8 @@ export default { loadingServerHostForm: false, loadingProjectFolderSetup: false, loadingOPOauthForm: false, + loadingAuthorizationMethodForm: false, + loadingAuthorizationSettingForm: false, isOpenProjectInstanceValid: null, openProjectNotReachableErrorMessage: null, openProjectNotReachableErrorMessageDetails: null, @@ -446,6 +597,22 @@ export default { isDarkTheme: null, isAllTermsOfServiceSignedForUserOpenProject: true, userSettingDescription: USER_SETTINGS, + authMethods: AUTH_METHOD, + authMethodsLabel: AUTH_METHOD_LABEL, + // here 'Set' defines that the method is selected and saved in database (e.g authorizationMethodSet) + // whereas 'Selected' defines that it is the current selection (e.g currentAuthorizationMethodSelected) + authorizationMethod: { + // default authorization method is set to 'oauth2' + authorizationMethodSet: AUTH_METHOD.OAUTH2, + currentAuthorizationMethodSelected: null, + }, + authorizationSetting: { + oidcProviderSet: null, + targetedAudienceClientIdSet: null, + currentOIDCProviderSelected: null, + currentTargetedAudienceClientIdSelected: null, + }, + registeredOidcProviders: [], } }, computed: { @@ -473,6 +640,12 @@ export default { isServerHostFormComplete() { return this.isFormCompleted.server }, + isAuthorizationMethodFormComplete() { + return this.isFormCompleted.authorizationMethod + }, + isAuthorizationSettingFormComplete() { + return this.isFormCompleted.authorizationSetting + }, isOPOAuthFormComplete() { return this.isFormCompleted.opOauth }, @@ -488,6 +661,12 @@ export default { isServerHostFormInView() { return this.formMode.server === F_MODES.VIEW }, + isAuthorizationMethodFormInViewMode() { + return this.formMode.authorizationMethod === F_MODES.VIEW + }, + isAuthorizationSettingsInViewMode() { + return this.formMode.authorizationSetting === F_MODES.VIEW + }, isOPOAuthFormInView() { return this.formMode.opOauth === F_MODES.VIEW }, @@ -500,12 +679,24 @@ export default { isOPOAuthFormInDisableMode() { return this.formMode.opOauth === F_MODES.DISABLE }, + isAuthorizationFormInDisabledMode() { + return this.formMode.authorizationMethod === F_MODES.DISABLE + }, + isAuthorizationSettingFormInDisabledMode() { + return this.formMode.authorizationSetting === F_MODES.DISABLE + }, isOPUserAppPasswordFormInEdit() { return this.formMode.opUserAppPassword === F_MODES.EDIT }, isProjectFolderSetupFormInEdit() { return this.formMode.projectFolderSetUp === F_MODES.EDIT }, + isAuthorizationFormInEditMode() { + return this.formMode.authorizationMethod === F_MODES.EDIT + }, + isAuthorizationSettingInEditMode() { + return this.formMode.authorizationSetting === F_MODES.EDIT + }, isNcOAuthFormInDisableMode() { return this.formMode.ncOauth === F_MODES.DISABLE }, @@ -563,14 +754,39 @@ export default { const htmlLink = `${linkText}` return t('integration_openproject', 'Server-side encryption is active, but encryption for Group Folders is not yet enabled. To ensure secure storage of files in project folders, please follow the configuration steps in the {htmlLink}.', { htmlLink }, null, { escape: false, sanitize: false }) }, - isIntegrationComplete() { + getAuthorizationMethodHintText() { + const linkText = t('integration_openproject', 'authorization methods you can use with OpenProject') + const htmlLink = `${linkText}` + return t('integration_openproject', 'Please read our guide on {htmlLink}.', { htmlLink }, null, { escape: false, sanitize: false }) + }, + getOIDCAppNotInstalledHintText() { + const linkText = t('integration_openproject', 'User OIDC') + const url = generateUrl('settings/apps/files/user_oidc') + const htmlLink = `${linkText}` + return t('integration_openproject', 'Please install the {htmlLink} app to be able to use Keycloak for authorization with OpenProject.', { htmlLink }, null, { escape: false, sanitize: false }) + }, + getConfigureOIDCHintText() { + const linkText = t('integration_openproject', 'User OIDC app') + const htmlLink = `${linkText}` + return t('integration_openproject', 'You can configure OIDC providers in the {htmlLink}.', { htmlLink }, null, { escape: false, sanitize: false }) + }, + isIntegrationCompleteWithOauth2() { return (this.isServerHostFormComplete + && this.isAuthorizationMethodFormComplete && this.isOPOAuthFormComplete && this.isNcOAuthFormComplete && this.isManagedGroupFolderSetUpComplete && !this.isOPUserAppPasswordFormInEdit ) }, + isIntegrationCompleteWithOIDC() { + return (this.isServerHostFormComplete + && this.isAuthorizationMethodFormComplete + && this.isAuthorizationSettingFormComplete + && this.isManagedGroupFolderSetUpComplete + && !this.isOPUserAppPasswordFormInEdit + ) + }, isSetupCompleteWithoutProjectFolders() { if (this.isProjectFolderSetupFormInEdit) { return false @@ -590,6 +806,30 @@ export default { return this.state.encryption_info.server_side_encryption_enabled && !this.state.encryption_info.encryption_enabled_for_groupfolders }, + getSelectedAuthenticatedMethod() { + return this.authorizationMethod.authorizationMethodSet === this.authMethods.OIDC + ? this.authMethodsLabel.OIDC + : this.authMethodsLabel.OAUTH2 + }, + isAuthorizationMethodSelected() { + return this.authorizationMethod.currentAuthorizationMethodSelected === this.authorizationMethod.authorizationMethodSet + }, + isAuthorizationSettingsSelected() { + const { oidcProviderSet, currentOIDCProviderSelected } = this.authorizationSetting + return currentOIDCProviderSelected === null + || !this.getCurrentSelectedTargetedClientId + || (oidcProviderSet === currentOIDCProviderSelected && this.authorizationSetting.targetedAudienceClientIdSet === this.getCurrentSelectedTargetedClientId) + || (this.authorizationSetting.targetedAudienceClientIdSet === this.getCurrentSelectedTargetedClientId && oidcProviderSet === currentOIDCProviderSelected) + }, + getCurrentSelectedOIDCProvider() { + return this.authorizationSetting.currentOIDCProviderSelected + }, + getCurrentSelectedTargetedClientId() { + return this.state.authorization_settings.targeted_audience_client_id + }, + isOIDCAppInstalledAndEnabled() { + return this.state.user_oidc_enabled + }, }, created() { this.init() @@ -615,22 +855,70 @@ export default { } else { this.textLabelProjectFolderSetupButton = this.buttonTextLabel.keepCurrentChange } - if (this.state.openproject_instance_url && this.state.openproject_client_id && this.state.openproject_client_secret && this.state.nc_oauth_client) { + // for oauth2 authorization + if (this.state.openproject_instance_url + && this.state.openproject_client_id + && this.state.openproject_client_secret + && this.state.nc_oauth_client + ) { + this.showDefaultManagedProjectFolders = true + } + // for oidc authorization + if (this.state.authorization_method === AUTH_METHOD.OIDC + && this.state.openproject_instance_url + && this.state.authorization_settings.oidc_provider + && this.state.authorization_settings.targeted_audience_client_id + ) { + this.showDefaultManagedProjectFolders = true + } + if (this.state.fresh_project_folder_setup === false) { this.showDefaultManagedProjectFolders = true } if (this.state.openproject_instance_url) { this.formMode.server = F_MODES.VIEW this.isFormCompleted.server = true } + if (this.state.authorization_method) { + this.formMode.authorizationMethod = F_MODES.VIEW + this.isFormCompleted.authorizationMethod = true + this.authorizationMethod.authorizationMethodSet = this.authorizationMethod.currentAuthorizationMethodSelected = this.state.authorization_method + } + if (this.state.openproject_instance_url && this.state.authorization_method) { + if (this.state.authorization_method === AUTH_METHOD.OAUTH2) { + if (!this.state.openproject_client_id || !this.state.openproject_client_secret) { + this.formMode.authorizationSetting = F_MODES.EDIT + } + } + if (this.state.authorization_method === AUTH_METHOD.OIDC) { + if (!this.state.authorization_settings.oidc_provider || !this.state.authorization_settings.targeted_audience_client_id) { + this.formMode.authorizationSetting = F_MODES.EDIT + } + } + } + if (this.state.authorization_method === AUTH_METHOD.OIDC + && this.state.authorization_settings.oidc_provider + && this.state.authorization_settings.targeted_audience_client_id + ) { + this.formMode.authorizationSetting = F_MODES.VIEW + this.isFormCompleted.authorizationSetting = true + this.authorizationSetting.oidcProviderSet = this.authorizationSetting.currentOIDCProviderSelected = this.state.authorization_settings.oidc_provider + this.authorizationSetting.targetedAudienceClientIdSet = this.authorizationSetting.currentTargetedAudienceClientIdSelected = this.state.authorization_settings.targeted_audience_client_id + } if (!!this.state.openproject_client_id && !!this.state.openproject_client_secret) { this.formMode.opOauth = F_MODES.VIEW this.isFormCompleted.opOauth = true } if (this.state.openproject_instance_url) { - if (!this.state.openproject_client_id || !this.state.openproject_client_secret) { + if (!this.state.authorization_method) { + this.formMode.authorizationMethod = F_MODES.EDIT + } + } + if (this.state.openproject_instance_url && this.state.authorization_method) { + if (!this.state.openproject_client_id && !this.state.openproject_client_secret) { this.formMode.opOauth = F_MODES.EDIT } } + if (this.state.nc_oauth_client) { this.formMode.ncOauth = F_MODES.VIEW this.isFormCompleted.ncOauth = true @@ -644,7 +932,7 @@ export default { this.formMode.projectFolderSetUp = F_MODES.VIEW this.isFormCompleted.projectFolderSetUp = true } - if (this.formMode.ncOauth === F_MODES.VIEW) { + if (this.formMode.ncOauth === F_MODES.VIEW || this.formMode.authorizationSetting === F_MODES.VIEW) { this.showDefaultManagedProjectFolders = true } if (this.showDefaultManagedProjectFolders) { @@ -659,6 +947,10 @@ export default { this.textLabelProjectFolderSetupButton = this.buttonTextLabel.keepCurrentChange } this.isProjectFolderSwitchEnabled = this.currentProjectFolderState === true + + if (this.state.oidc_provider) { + this.registeredOidcProviders = this.state.oidc_provider + } } }, projectFolderSetUpErrorMessageDescription(errorKey) { @@ -678,12 +970,30 @@ export default { setServerHostFormToViewMode() { this.formMode.server = F_MODES.VIEW }, + setAuthorizationMethodToViewMode() { + this.formMode.authorizationMethod = F_MODES.VIEW + this.isFormCompleted.authorizationMethod = true + this.authorizationMethod.authorizationMethodSet = this.authorizationMethod.currentAuthorizationMethodSelected + }, + setAuthorizationSettingToViewMode() { + this.formMode.authorizationSetting = F_MODES.VIEW + this.isFormCompleted.authorizationSetting = true + this.state.authorization_settings.targeted_audience_client_id = this.authorizationSetting.currentTargetedAudienceClientIdSelected + }, setServerHostFormToEditMode() { this.formMode.server = F_MODES.EDIT // set the edit variable to the current saved value this.serverHostUrlForEdit = this.state.openproject_instance_url this.isOpenProjectInstanceValid = null }, + setAuthorizationMethodInEditMode() { + this.formMode.authorizationMethod = F_MODES.EDIT + this.isFormCompleted.authorizationMethod = false + }, + setAuthorizationSettingInEditMode() { + this.formMode.authorizationSetting = F_MODES.EDIT + this.isFormCompleted.authorizationSetting = false + }, setProjectFolderSetUpToEditMode() { this.formMode.projectFolderSetUp = F_MODES.EDIT this.isFormCompleted.projectFolderSetUp = false @@ -699,7 +1009,7 @@ export default { async setNCOAuthFormToViewMode() { this.formMode.ncOauth = F_MODES.VIEW this.isFormCompleted.ncOauth = true - if (!this.isIntegrationComplete && this.formMode.projectFolderSetUp !== F_MODES.EDIT && this.formMode.opUserAppPassword !== F_MODES.EDIT) { + if (!this.isIntegrationCompleteWithOauth2 && this.formMode.projectFolderSetUp !== F_MODES.EDIT && this.formMode.opUserAppPassword !== F_MODES.EDIT) { this.formMode.projectFolderSetUp = F_MODES.EDIT this.showDefaultManagedProjectFolders = true this.isProjectFolderSwitchEnabled = true @@ -767,8 +1077,8 @@ export default { this.state.openproject_instance_url = this.serverHostUrlForEdit this.formMode.server = F_MODES.VIEW this.isFormCompleted.server = true - if (!this.isFormCompleted.opOauth) { - this.formMode.opOauth = F_MODES.EDIT + if (!this.isFormCompleted.authorizationMethod) { + this.formMode.authorizationMethod = F_MODES.EDIT } } } @@ -787,6 +1097,42 @@ export default { } } }, + async saveAuthorizationMethodValue() { + this.isFormStep = FORM.AUTHORIZATION_METHOD + this.loadingAuthorizationMethodForm = true + const success = await this.saveOPOptions() + if (success) { + this.authorizationMethod.currentAuthorizationMethodSelected = this.authorizationMethod.authorizationMethodSet + this.formMode.authorizationMethod = F_MODES.VIEW + this.isFormCompleted.authorizationMethod = true + if (this.authorizationMethod.authorizationMethodSet === this.authMethods.OIDC && !this.isFormCompleted.authorizationSetting) { + this.formMode.authorizationSetting = F_MODES.EDIT + } else { + if (!this.isFormCompleted.opOauth) { + this.formMode.opOauth = F_MODES.EDIT + } + } + } + this.loadingAuthorizationMethodForm = false + }, + async saveOIDCAuthSetting() { + this.isFormStep = FORM.AUTHORIZATION_SETTING + this.loadingAuthorizationMethodForm = true + this.authorizationSetting.oidcProviderSet = this.getCurrentSelectedOIDCProvider + this.authorizationSetting.targetedAudienceClientIdSet = this.state.authorization_settings.targeted_audience_client_id + const success = await this.saveOPOptions() + if (success) { + this.formMode.authorizationSetting = F_MODES.VIEW + this.isFormCompleted.authorizationSetting = true + if (!this.isIntegrationCompleteWithOIDC && this.formMode.projectFolderSetUp !== F_MODES.EDIT && this.formMode.opUserAppPassword !== F_MODES.EDIT) { + this.formMode.projectFolderSetUp = F_MODES.EDIT + this.showDefaultManagedProjectFolders = true + this.isProjectFolderSwitchEnabled = true + this.textLabelProjectFolderSetupButton = this.buttonTextLabel.completeWithProjectFolderSetup + } + } + this.loadingAuthorizationMethodForm = false + }, resetOPOAuthClientValues() { OC.dialogs.confirmDestructive( t('integration_openproject', 'If you proceed you will need to update these settings with the new OpenProject OAuth credentials. Also, all users will need to reauthorize access to their OpenProject account.'), @@ -805,6 +1151,39 @@ export default { true, ) }, + async selectAuthorizationMethod() { + // open the confirmation dialog when only swithing back and forth between two authorization method + if (this.isAuthorizationFormInEditMode && this.authorizationMethod.currentAuthorizationMethodSelected !== null) { + await OC.dialogs.confirmDestructive( + t('integration_openproject', `If you proceed this method, you will have an ${this.authorizationMethod.authorizationMethodSet.toUpperCase()} based authorization configuration which will delete all the configuration setting for current ${this.authorizationMethod.currentAuthorizationMethodSelected.toUpperCase()} based authorization. You can switch back to it anytime.`), + t('integration_openproject', 'Switch Authorization Method'), + { + type: OC.dialogs.YES_NO_BUTTONS, + confirm: t('integration_openproject', 'Yes, switch'), + confirmClasses: 'error', + cancel: t('integration_openproject', 'Cancel'), + }, + async (result) => { + if (result) { + // here we switch either to oidc or oauth2 configuration + const authMethod = this.authorizationMethod.authorizationMethodSet + if (authMethod === AUTH_METHOD.OAUTH2) { + this.state.authorization_settings.targeted_audience_client_id = null + this.authorizationSetting.currentOIDCProviderSelected = null + } else { + this.state.openproject_client_id = '' + this.state.openproject_client_secret = '' + } + await this.saveAuthorizationMethodValue() + } + window.location.reload() + }, + true, + ) + return '' + } + await this.saveAuthorizationMethodValue() + }, async clearOPOAuthClientValues() { this.isFormStep = FORM.OP_OAUTH this.formMode.opOauth = F_MODES.EDIT @@ -829,13 +1208,14 @@ export default { }, async (result) => { if (result) { - await this.resetAllAppValues() + const authMethod = this.authorizationMethod.authorizationMethodSet + await this.resetAllAppValues(authMethod) } }, true, ) }, - async resetAllAppValues() { + async resetAllAppValues(authMethod) { // to avoid general console errors, we need to set the form to // editor mode so that we can update the form fields with null values // also, form completeness should be set to false @@ -843,12 +1223,18 @@ export default { this.isFormCompleted.opOauth = false this.formMode.server = F_MODES.EDIT this.isFormCompleted.server = false - this.state.openproject_client_id = null - this.state.openproject_client_secret = null this.state.default_enable_navigation = false - this.state.openproject_instance_url = null this.state.default_enable_unified_search = false this.oPUserAppPassword = null + this.authorizationMethod.authorizationMethodSet = null + this.state.openproject_client_id = null + this.state.openproject_client_secret = null + this.state.openproject_instance_url = null + // if the authorization method is "oidc" + if (authMethod === AUTH_METHOD.OIDC) { + this.state.authorization_settings.targeted_audience_client_id = null + this.authorizationSetting.currentOIDCProviderSelected = null + } await this.saveOPOptions() window.location.reload() }, @@ -952,21 +1338,46 @@ export default { default_enable_navigation: this.state.default_enable_navigation, default_enable_unified_search: this.state.default_enable_unified_search, } - if (this.state.openproject_instance_url === null && this.state.openproject_client_secret === null && this.state.openproject_client_id === null) { - // doing whole reset + if (this.state.openproject_instance_url === null && this.authorizationMethod.authorizationMethodSet === null) { + // by default, it will be an oauth2 reset values = { ...values, + authorization_method: this.authorizationMethod.authorizationMethodSet, setup_project_folder: false, setup_app_password: false, } + if (this.authorizationMethod.currentAuthorizationMethodSelected === AUTH_METHOD.OIDC + && this.authorizationSetting.currentOIDCProviderSelected === null + && this.state.authorization_settings.targeted_audience_client_id === null) { + // when reset is oidc + values = { + ...values, + oidc_provider: this.getCurrentSelectedOIDCProvider, + targeted_audience_client_id: this.getCurrentSelectedTargetedClientId, + } + } + } else if (this.isFormStep === FORM.AUTHORIZATION_SETTING) { + values = { + oidc_provider: this.getCurrentSelectedOIDCProvider, + targeted_audience_client_id: this.getCurrentSelectedTargetedClientId, + } + } else if (this.isFormStep === FORM.AUTHORIZATION_METHOD) { + values = { + ...values, + authorization_method: this.authorizationMethod.authorizationMethodSet, + oidc_provider: this.isIntegrationCompleteWithOIDC ? this.getCurrentSelectedOIDCProvider : null, + targeted_audience_client_id: this.isIntegrationCompleteWithOIDC ? this.getCurrentSelectedTargetedClientId : null, + } } else if (this.isFormStep === FORM.GROUP_FOLDER) { if (!this.isProjectFolderSwitchEnabled) { values = { + authorization_method: this.authorizationMethod.authorizationMethodSet, setup_project_folder: false, setup_app_password: false, } } else if (this.isProjectFolderSwitchEnabled === true) { values = { + authorization_method: this.authorizationMethod.authorizationMethodSet, setup_project_folder: !this.isProjectFolderAlreadySetup, setup_app_password: this.opUserAppPassword !== true, } @@ -1136,6 +1547,9 @@ export default { ) }) }, + onSelectOIDCProvider(selectedOption) { + this.authorizationSetting.currentOIDCProviderSelected = selectedOption + }, }, } @@ -1218,11 +1632,45 @@ export default { } .note-card { max-width: 900px; - &--info-description, &--error-description, &--warning-description { - .link { - color: #1a67a3 !important; - font-style: normal; + } + .link { + color: #1a67a3 !important; + font-style: normal; + } + .authorization-method { + &--description { + font-size: 14px; + .title { + font-weight: 700; } + .description { + margin-top: 0.1rem; + } + } + &--options { + margin-top: 1rem; + .radio-check { + font-weight: 500; + } + .oidc-app-check-description { + margin-left: 2.4rem; + font-size: 14px; + } + } + } + .authorization-settings { + &--content { + max-width: 550px; + &--label { + font-weight: 700; + font-size: .875rem; + } + &--client { + margin-top: 0.7rem; + } + } + .description { + margin-top: 0.1rem; } } } diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue index 1e9495a1..271d2738 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -1,12 +1,15 @@

- +
@@ -51,7 +54,7 @@ import SettingsTitle from '../components/settings/SettingsTitle.vue' import OAuthConnectButton from './OAuthConnectButton.vue' import CheckBox from './settings/CheckBox.vue' import { translate as t } from '@nextcloud/l10n' -import { checkOauthConnectionResult, USER_SETTINGS } from '../utils.js' +import { checkOauthConnectionResult, USER_SETTINGS, AUTH_METHOD } from '../utils.js' import { NcButton } from '@nextcloud/vue' export default { @@ -68,6 +71,7 @@ export default { oauthConnectionErrorMessage: loadState('integration_openproject', 'oauth-connection-error-message'), oauthConnectionResult: loadState('integration_openproject', 'oauth-connection-result'), userSettingDescription: USER_SETTINGS, + authMethods: AUTH_METHOD, } }, computed: { @@ -76,6 +80,15 @@ export default { return this.state.token && this.state.token !== '' && this.state.user_name && this.state.user_name !== '' }, + isNonOidcUserConnectedViaOidc() { + return !!(this.state.authorization_method === AUTH_METHOD.OIDC && this.state.admin_config_ok && !this.state.token) + }, + showOAuthConnectButton() { + if (this.connected) { + return false + } + return !(this.state.admin_config_ok === true && this.state.authorization_method === this.authMethods.OIDC) + }, }, watch: { 'state.search_enabled'(newVal) { @@ -91,7 +104,9 @@ export default { }, mounted() { - checkOauthConnectionResult(this.oauthConnectionResult, this.oauthConnectionErrorMessage) + if (this.state.authorization_method === this.authMethods.OAUTH2) { + checkOauthConnectionResult(this.oauthConnectionResult, this.oauthConnectionErrorMessage) + } }, methods: { @@ -164,5 +179,9 @@ export default { text-align: left; padding: 0; } + .demo-error-oidc { + color: red; + margin-top: 20px; + } } diff --git a/src/components/tab/EmptyContent.vue b/src/components/tab/EmptyContent.vue index bf4b16f1..ce69f9d4 100644 --- a/src/components/tab/EmptyContent.vue +++ b/src/components/tab/EmptyContent.vue @@ -17,7 +17,9 @@
- +
@@ -31,7 +33,7 @@ import OpenProjectIcon from '../icons/OpenProjectIcon.vue' import { generateUrl } from '@nextcloud/router' import { translate as t } from '@nextcloud/l10n' import OAuthConnectButton from '../OAuthConnectButton.vue' -import { STATE } from '../../utils.js' +import { AUTH_METHOD, STATE } from '../../utils.js' export default { name: 'EmptyContent', @@ -42,6 +44,10 @@ export default { required: true, default: STATE.OK, }, + authMethod: { + type: String, + required: true, + }, isAdminConfigOk: { type: Boolean, required: true, @@ -79,7 +85,11 @@ export default { return this.state === STATE.OK }, showConnectButton() { - return [STATE.NO_TOKEN, STATE.ERROR].includes(this.state) + // show button when admin config is not configured (either oidc or be oauth2) + if (!this.isAdminConfigOk) { + return true + } + return this.authMethod === AUTH_METHOD.OAUTH2 && [STATE.NO_TOKEN, STATE.ERROR].includes(this.state) }, emptyContentTitleMessage() { if (this.state === STATE.NO_TOKEN) { diff --git a/src/utils.js b/src/utils.js index f873d61d..77b955e2 100644 --- a/src/utils.js +++ b/src/utils.js @@ -43,10 +43,12 @@ export const F_MODES = { export const FORM = { SERVER: 0, - OP_OAUTH: 1, - NC_OAUTH: 2, - GROUP_FOLDER: 3, - APP_PASSWORD: 4, + AUTHORIZATION_METHOD: 1, + AUTHORIZATION_SETTING: 2, + OP_OAUTH: 3, + NC_OAUTH: 4, + GROUP_FOLDER: 5, + APP_PASSWORD: 6, } export const WORKPACKAGES_SEARCH_ORIGIN = { @@ -63,3 +65,13 @@ export const NO_OPTION_TEXT_STATE = { SEARCHING: 1, RESULT: 2, } + +export const AUTH_METHOD = { + OAUTH2: 'oauth2', + OIDC: 'oidc', +} + +export const AUTH_METHOD_LABEL = { + OAUTH2: t('integration_openproject', 'OAuth2 two-way authorization code flow'), + OIDC: t('integration_openproject', 'OpenID identity provider'), +} diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index 0f71b5b3..3f8acc5f 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -6,11 +6,17 @@ :loading="isLoading" @markAsRead="onMarkAsRead"> @@ -21,7 +27,7 @@ import { generateUrl } from '@nextcloud/router' import { NcDashboardWidget } from '@nextcloud/vue' import { showError, showSuccess } from '@nextcloud/dialogs' import { loadState } from '@nextcloud/initial-state' -import { checkOauthConnectionResult, STATE } from '../utils.js' +import { AUTH_METHOD, checkOauthConnectionResult, STATE } from '../utils.js' import { translate as t } from '@nextcloud/l10n' import EmptyContent from '../components/tab/EmptyContent.vue' @@ -46,7 +52,9 @@ export default { state: STATE.LOADING, oauthConnectionErrorMessage: loadState('integration_openproject', 'oauth-connection-error-message'), oauthConnectionResult: loadState('integration_openproject', 'oauth-connection-result'), - isAdminConfigOk: loadState('integration_openproject', 'admin-config-status'), + isAdminConfigOk: loadState('integration_openproject', 'admin_config_ok'), + userHasOidcToken: loadState('integration_openproject', 'user-has-oidc-token'), + authMethod: loadState('integration_openproject', 'authorization_method'), settingsUrl: generateUrl('/settings/user/openproject'), themingColor: OCA.Theming ? OCA.Theming.color.replace('#', '') : '0082C9', windowVisibility: true, @@ -56,6 +64,7 @@ export default { icon: 'icon-checkmark', }, }, + authMethods: AUTH_METHOD, } }, computed: { @@ -68,6 +77,9 @@ export default { showMoreUrl() { return this.openprojectUrl + '/notifications' }, + isNonOidcUserConnectedViaOidc() { + return !!(this.authMethod === AUTH_METHOD.OIDC && this.isAdminConfigOk && !this.userHasOidcToken) + }, items() { const notifications = [] for (const key in this.notifications) { @@ -85,6 +97,10 @@ export default { return notifications }, emptyContentMessage() { + // for oidc connection currently we do not show any error to user + if (this.isNonOidcUserConnectedViaOidc === true) { + return + } if (this.state === STATE.NO_TOKEN) { return t('integration_openproject', 'No connection with OpenProject') } else if (this.state === STATE.CONNECTION_ERROR) { @@ -108,7 +124,9 @@ export default { }, }, mounted() { - checkOauthConnectionResult(this.oauthConnectionResult, this.oauthConnectionErrorMessage) + if (this.authMethod === this.authMethods.OAUTH2) { + checkOauthConnectionResult(this.oauthConnectionResult, this.oauthConnectionErrorMessage) + } }, beforeDestroy() { @@ -279,4 +297,9 @@ export default { :deep(.connect-button) { margin-top: 10px; } + +.demo-error-oidc { + color: red; + margin-top: 20px; +} diff --git a/src/views/LinkMultipleFilesModal.vue b/src/views/LinkMultipleFilesModal.vue index 3f5ebe4c..5d06e5a8 100644 --- a/src/views/LinkMultipleFilesModal.vue +++ b/src/views/LinkMultipleFilesModal.vue @@ -57,6 +57,7 @@ @@ -105,7 +106,8 @@ export default { state: STATE.LOADING, fileInfos: [], alreadyLinkedWorkPackage: [], - isAdminConfigOk: loadState('integration_openproject', 'admin-config-status'), + isAdminConfigOk: loadState('integration_openproject', 'admin_config_ok'), + authMethod: loadState('integration_openproject', 'authorization_method'), oauthConnectionErrorMessage: loadState('integration_openproject', 'oauth-connection-error-message'), oauthConnectionResult: loadState('integration_openproject', 'oauth-connection-result'), searchOrigin: WORKPACKAGES_SEARCH_ORIGIN.LINK_MULTIPLE_FILES_MODAL, @@ -148,7 +150,9 @@ export default { }, mounted() { - checkOauthConnectionResult(this.oauthConnectionResult, this.oauthConnectionErrorMessage) + if (this.authMethod === 'oauth2') { + checkOauthConnectionResult(this.oauthConnectionResult, this.oauthConnectionErrorMessage) + } }, methods: { async relinkRemainingFilesToWorkPackage() { diff --git a/src/views/ProjectsTab.vue b/src/views/ProjectsTab.vue index 696e91ae..160cad2b 100644 --- a/src/views/ProjectsTab.vue +++ b/src/views/ProjectsTab.vue @@ -58,6 +58,7 @@ id="openproject-empty-content" :state="state" :file-info="fileInfo" + :auth-method="authMethod" :is-admin-config-ok="isAdminConfigOk" /> @@ -76,7 +77,7 @@ import { showSuccess, showError } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' import { loadState } from '@nextcloud/initial-state' import { workpackageHelper } from '../utils/workpackageHelper.js' -import { STATE, WORKPACKAGES_SEARCH_ORIGIN, checkOauthConnectionResult } from '../utils.js' +import { STATE, WORKPACKAGES_SEARCH_ORIGIN, AUTH_METHOD, checkOauthConnectionResult } from '../utils.js' export default { name: 'ProjectsTab', @@ -96,7 +97,8 @@ export default { workpackages: [], oauthConnectionErrorMessage: loadState('integration_openproject', 'oauth-connection-error-message'), oauthConnectionResult: loadState('integration_openproject', 'oauth-connection-result'), - isAdminConfigOk: loadState('integration_openproject', 'admin-config-status'), + isAdminConfigOk: loadState('integration_openproject', 'admin_config_ok'), + authMethod: loadState('integration_openproject', 'authorization_method'), color: null, openprojectUrl: loadState('integration_openproject', 'openproject-url'), searchOrigin: WORKPACKAGES_SEARCH_ORIGIN.PROJECT_TAB, @@ -119,7 +121,9 @@ export default { }, }, mounted() { - checkOauthConnectionResult(this.oauthConnectionResult, this.oauthConnectionErrorMessage) + if (this.authMethod === AUTH_METHOD.OAUTH2) { + checkOauthConnectionResult(this.oauthConnectionResult, this.oauthConnectionErrorMessage) + } }, methods: { /** diff --git a/src/views/WorkPackagePickerElement.vue b/src/views/WorkPackagePickerElement.vue index 4a0d6301..13ace463 100644 --- a/src/views/WorkPackagePickerElement.vue +++ b/src/views/WorkPackagePickerElement.vue @@ -28,7 +28,7 @@ ref="linkPicker" :is-smart-picker="true" :file-info="fileInfo" - :is-disabled="isLoading || !isAdminConfigOk || !isStateOk" + :is-disabled="isLoading || !enableSearchInput || !isStateOk" :linked-work-packages="linkedWorkPackages" @submit="onSubmit" />
@@ -36,9 +36,10 @@ + :is-admin-config-ok="adminConfigState.isAdminConfigOk" />
@@ -46,7 +47,7 @@