diff --git a/src/app/core/provider/sentry.provider.spec.ts b/src/app/core/provider/sentry.provider.spec.ts index 85d9b609c..b76e1149e 100644 --- a/src/app/core/provider/sentry.provider.spec.ts +++ b/src/app/core/provider/sentry.provider.spec.ts @@ -4,8 +4,6 @@ import { provideOSFCore } from '@testing/osf.testing.provider'; import { SENTRY_PROVIDER, SENTRY_TOKEN } from './sentry.provider'; -import * as Sentry from '@sentry/angular'; - describe('Provider: Sentry', () => { beforeEach(() => { TestBed.configureTestingModule({ @@ -15,7 +13,7 @@ describe('Provider: Sentry', () => { it('should provide the Sentry module via the injection token', () => { const provided = TestBed.inject(SENTRY_TOKEN); - expect(provided).toBe(Sentry); + expect(provided).toBe(SENTRY_PROVIDER.useValue); expect(typeof provided.captureException).toBe('function'); }); }); diff --git a/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.spec.ts b/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.spec.ts index e69a564bc..9394ff22c 100644 --- a/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.spec.ts +++ b/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.spec.ts @@ -1,37 +1,26 @@ -import { Store } from '@ngxs/store'; - import { MockComponent, MockProvider } from 'ng-mocks'; -import { Mock } from 'vitest'; - import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ReadonlyInputComponent } from '@osf/shared/components/readonly-input/readonly-input.component'; -import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; -import { LoaderService } from '@osf/shared/services/loader.service'; -import { ToastService } from '@osf/shared/services/toast.service'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { - CustomConfirmationServiceMock, - CustomConfirmationServiceMockType, -} from '@testing/providers/custom-confirmation-provider.mock'; -import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; + ExternalIdentityDeletionServiceMock, + ExternalIdentityDeletionServiceMockType, +} from '@testing/providers/external-identity-deletion-service.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; import { ExternalIdentity } from '../../models'; -import { AccountSettingsSelectors, DeleteExternalIdentity } from '../../store'; +import { ExternalIdentityDeletionService } from '../../services'; +import { AccountSettingsSelectors } from '../../store'; import { ConnectedIdentitiesComponent } from './connected-identities.component'; describe('ConnectedIdentitiesComponent', () => { let component: ConnectedIdentitiesComponent; let fixture: ComponentFixture; - let store: Store; - let loaderService: LoaderServiceMock; - let confirmationService: CustomConfirmationServiceMockType; - let toastService: ToastServiceMockType; + let externalIdentityDeletion: ExternalIdentityDeletionServiceMockType; const mockIdentity: ExternalIdentity = { id: 'orcid', @@ -40,24 +29,19 @@ describe('ConnectedIdentitiesComponent', () => { }; beforeEach(() => { - loaderService = new LoaderServiceMock(); - confirmationService = CustomConfirmationServiceMock.simple(); - toastService = ToastServiceMock.simple(); + externalIdentityDeletion = ExternalIdentityDeletionServiceMock.simple(); TestBed.configureTestingModule({ imports: [ConnectedIdentitiesComponent, MockComponent(ReadonlyInputComponent)], providers: [ provideOSFCore(), - MockProvider(LoaderService, loaderService), - MockProvider(CustomConfirmationService, confirmationService), - MockProvider(ToastService, toastService), + MockProvider(ExternalIdentityDeletionService, externalIdentityDeletion), provideMockStore({ signals: [{ selector: AccountSettingsSelectors.getExternalIdentities, value: [mockIdentity] }], }), ], }); - store = TestBed.inject(Store); fixture = TestBed.createComponent(ConnectedIdentitiesComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -71,36 +55,9 @@ describe('ConnectedIdentitiesComponent', () => { expect(component.externalIdentities()).toEqual([mockIdentity]); }); - it('should open delete confirmation with expected payload', () => { - component.deleteExternalIdentity(mockIdentity); - - expect(confirmationService.confirmDelete).toHaveBeenCalledWith({ - headerKey: 'settings.accountSettings.connectedIdentities.deleteDialog.header', - messageParams: { name: mockIdentity.id }, - messageKey: 'settings.accountSettings.connectedIdentities.deleteDialog.message', - onConfirm: expect.any(Function), - }); - }); - - it('should dispatch delete and show success when confirmation is accepted', () => { - (store.dispatch as Mock).mockClear(); - component.deleteExternalIdentity(mockIdentity); - - const { onConfirm } = confirmationService.confirmDelete.mock.calls[0][0]; - onConfirm(); - - expect(loaderService.show).toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith(new DeleteExternalIdentity(mockIdentity.id)); - expect(toastService.showSuccess).toHaveBeenCalledWith('settings.accountSettings.connectedIdentities.successDelete'); - expect(loaderService.hide).toHaveBeenCalled(); - }); - - it('should not dispatch delete when confirmation is not accepted', () => { - (store.dispatch as Mock).mockClear(); + it('should delegate deletion of the identity to the deletion service', () => { component.deleteExternalIdentity(mockIdentity); - expect(loaderService.show).not.toHaveBeenCalled(); - expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(DeleteExternalIdentity)); - expect(toastService.showSuccess).not.toHaveBeenCalled(); + expect(externalIdentityDeletion.confirmAndDelete).toHaveBeenCalledWith(mockIdentity.id); }); }); diff --git a/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.ts b/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.ts index fa57af0ae..15968b74c 100644 --- a/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.ts +++ b/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.ts @@ -1,20 +1,16 @@ -import { createDispatchMap, select } from '@ngxs/store'; +import { select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; -import { finalize } from 'rxjs'; - import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ReadonlyInputComponent } from '@osf/shared/components/readonly-input/readonly-input.component'; -import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; -import { LoaderService } from '@osf/shared/services/loader.service'; -import { ToastService } from '@osf/shared/services/toast.service'; import { ExternalIdentity } from '../../models'; -import { AccountSettingsSelectors, DeleteExternalIdentity } from '../../store'; +import { ExternalIdentityDeletionService } from '../../services'; +import { AccountSettingsSelectors } from '../../store'; @Component({ selector: 'osf-connected-identities', @@ -24,25 +20,11 @@ import { AccountSettingsSelectors, DeleteExternalIdentity } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ConnectedIdentitiesComponent { - private readonly customConfirmationService = inject(CustomConfirmationService); - private readonly loaderService = inject(LoaderService); - private readonly toastService = inject(ToastService); + private readonly externalIdentityDeletion = inject(ExternalIdentityDeletionService); - readonly actions = createDispatchMap({ deleteExternalIdentity: DeleteExternalIdentity }); readonly externalIdentities = select(AccountSettingsSelectors.getExternalIdentities); deleteExternalIdentity(identity: ExternalIdentity): void { - this.customConfirmationService.confirmDelete({ - headerKey: 'settings.accountSettings.connectedIdentities.deleteDialog.header', - messageParams: { name: identity.id }, - messageKey: 'settings.accountSettings.connectedIdentities.deleteDialog.message', - onConfirm: () => { - this.loaderService.show(); - this.actions - .deleteExternalIdentity(identity.id) - .pipe(finalize(() => this.loaderService.hide())) - .subscribe(() => this.toastService.showSuccess('settings.accountSettings.connectedIdentities.successDelete')); - }, - }); + this.externalIdentityDeletion.confirmAndDelete(identity.id); } } diff --git a/src/app/features/settings/account-settings/services/account-settings.service.spec.ts b/src/app/features/settings/account-settings/services/account-settings.service.spec.ts new file mode 100644 index 000000000..7b732a216 --- /dev/null +++ b/src/app/features/settings/account-settings/services/account-settings.service.spec.ts @@ -0,0 +1,37 @@ +import { TestBed } from '@angular/core/testing'; + +import { BYPASS_ERROR_INTERCEPTOR } from '@core/interceptors/error-interceptor.tokens'; +import { JsonApiService } from '@osf/shared/services/json-api.service'; + +import { MOCK_API_URL, provideMockEnvironment } from '@testing/providers/environment.provider.mock'; +import { JsonApiServiceMock, JsonApiServiceMockType } from '@testing/providers/json-api.service.mock'; + +import { AccountSettingsService } from './account-settings.service'; + +describe('AccountSettingsService', () => { + let service: AccountSettingsService; + let jsonApiService: JsonApiServiceMockType; + + beforeEach(() => { + jsonApiService = JsonApiServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [ + AccountSettingsService, + provideMockEnvironment(), + { provide: JsonApiService, useValue: jsonApiService }, + ], + }); + + service = TestBed.inject(AccountSettingsService); + }); + + it('should delete an external identity with the error interceptor bypassed', () => { + service.deleteExternalIdentity('orcid').subscribe(); + + expect(jsonApiService.delete).toHaveBeenCalledTimes(1); + const [url, , , context] = jsonApiService.delete.mock.calls[0]; + expect(url).toBe(`${MOCK_API_URL}/users/me/settings/identities/orcid/`); + expect(context?.get(BYPASS_ERROR_INTERCEPTOR)).toBe(true); + }); +}); diff --git a/src/app/features/settings/account-settings/services/account-settings.service.ts b/src/app/features/settings/account-settings/services/account-settings.service.ts index f46142573..e58fc1696 100644 --- a/src/app/features/settings/account-settings/services/account-settings.service.ts +++ b/src/app/features/settings/account-settings/services/account-settings.service.ts @@ -1,7 +1,9 @@ import { map, Observable } from 'rxjs'; +import { HttpContext } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; +import { BYPASS_ERROR_INTERCEPTOR } from '@core/interceptors/error-interceptor.tokens'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { UserMapper } from '@osf/shared/mappers/user'; import { UserModel } from '@osf/shared/models/user/user.model'; @@ -113,6 +115,13 @@ export class AccountSettingsService { } deleteExternalIdentity(id: string): Observable { - return this.jsonApiService.delete(`${this.apiUrl}/users/me/settings/identities/${id}/`); + const context = new HttpContext().set(BYPASS_ERROR_INTERCEPTOR, true); + + return this.jsonApiService.delete( + `${this.apiUrl}/users/me/settings/identities/${id}/`, + undefined, + undefined, + context + ); } } diff --git a/src/app/features/settings/account-settings/services/external-identity-deletion.service.spec.ts b/src/app/features/settings/account-settings/services/external-identity-deletion.service.spec.ts new file mode 100644 index 000000000..d48243ff6 --- /dev/null +++ b/src/app/features/settings/account-settings/services/external-identity-deletion.service.spec.ts @@ -0,0 +1,108 @@ +import { Store } from '@ngxs/store'; + +import { MockProvider } from 'ng-mocks'; + +import { throwError } from 'rxjs'; + +import { Mock } from 'vitest'; + +import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; + +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + +import { DeleteExternalIdentity } from '../store'; + +import { ExternalIdentityDeletionService } from './external-identity-deletion.service'; + +describe('ExternalIdentityDeletionService', () => { + let service: ExternalIdentityDeletionService; + let store: Store; + let loaderService: LoaderServiceMock; + let confirmationService: CustomConfirmationServiceMockType; + let toastService: ToastServiceMockType; + + const identityId = 'ORCID'; + + const confirm = () => { + const { onConfirm } = confirmationService.confirmDelete.mock.calls[0][0]; + onConfirm(); + }; + + beforeEach(() => { + loaderService = new LoaderServiceMock(); + confirmationService = CustomConfirmationServiceMock.simple(); + toastService = ToastServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [ + provideOSFCore(), + MockProvider(LoaderService, loaderService), + MockProvider(CustomConfirmationService, confirmationService), + MockProvider(ToastService, toastService), + provideMockStore(), + ], + }); + + store = TestBed.inject(Store); + service = TestBed.inject(ExternalIdentityDeletionService); + }); + + it('should open delete confirmation with expected payload', () => { + service.confirmAndDelete(identityId); + + expect(confirmationService.confirmDelete).toHaveBeenCalledWith({ + headerKey: 'settings.accountSettings.connectedIdentities.deleteDialog.header', + messageParams: { name: identityId }, + messageKey: 'settings.accountSettings.connectedIdentities.deleteDialog.message', + onConfirm: expect.any(Function), + }); + }); + + it('should not dispatch delete until confirmation is accepted', () => { + (store.dispatch as Mock).mockClear(); + service.confirmAndDelete(identityId); + + expect(loaderService.show).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + }); + + it('should dispatch delete and show success when confirmation is accepted', () => { + (store.dispatch as Mock).mockClear(); + service.confirmAndDelete(identityId); + confirm(); + + expect(loaderService.show).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteExternalIdentity(identityId)); + expect(toastService.showSuccess).toHaveBeenCalledWith('settings.accountSettings.connectedIdentities.successDelete'); + expect(toastService.showError).not.toHaveBeenCalled(); + expect(loaderService.hide).toHaveBeenCalled(); + }); + + it.each([ + [503, 'settings.accountSettings.connectedIdentities.errors.retryLater'], + [409, 'settings.accountSettings.connectedIdentities.errors.signInViaOrcid'], + [500, 'settings.accountSettings.connectedIdentities.errors.contactSupport'], + [404, 'settings.accountSettings.connectedIdentities.errors.contactSupport'], + ])('should show the matching error toast for status %i', (status, messageKey) => { + (store.dispatch as Mock).mockReturnValue(throwError(() => new HttpErrorResponse({ status }))); + service.confirmAndDelete(identityId); + confirm(); + + expect(toastService.showError).toHaveBeenCalledWith(messageKey); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + expect(loaderService.hide).toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/settings/account-settings/services/external-identity-deletion.service.ts b/src/app/features/settings/account-settings/services/external-identity-deletion.service.ts new file mode 100644 index 000000000..84690e445 --- /dev/null +++ b/src/app/features/settings/account-settings/services/external-identity-deletion.service.ts @@ -0,0 +1,54 @@ +import { createDispatchMap } from '@ngxs/store'; + +import { finalize } from 'rxjs'; + +import { HttpErrorResponse } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; + +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { DeleteExternalIdentity } from '../store'; + +@Injectable({ + providedIn: 'root', +}) +export class ExternalIdentityDeletionService { + private readonly customConfirmationService = inject(CustomConfirmationService); + private readonly loaderService = inject(LoaderService); + private readonly toastService = inject(ToastService); + + private readonly actions = createDispatchMap({ deleteExternalIdentity: DeleteExternalIdentity }); + + confirmAndDelete(identityId: string): void { + this.customConfirmationService.confirmDelete({ + headerKey: 'settings.accountSettings.connectedIdentities.deleteDialog.header', + messageParams: { name: identityId }, + messageKey: 'settings.accountSettings.connectedIdentities.deleteDialog.message', + onConfirm: () => { + this.loaderService.show(); + this.actions + .deleteExternalIdentity(identityId) + .pipe(finalize(() => this.loaderService.hide())) + .subscribe({ + next: () => this.toastService.showSuccess('settings.accountSettings.connectedIdentities.successDelete'), + error: (error: HttpErrorResponse) => this.toastService.showError(this.getDeleteErrorKey(error)), + }); + }, + }); + } + + private getDeleteErrorKey(error: HttpErrorResponse): string { + const prefix = 'settings.accountSettings.connectedIdentities.errors'; + + switch (error.status) { + case 503: + return `${prefix}.retryLater`; + case 409: + return `${prefix}.signInViaOrcid`; + default: + return `${prefix}.contactSupport`; + } + } +} diff --git a/src/app/features/settings/account-settings/services/index.ts b/src/app/features/settings/account-settings/services/index.ts index 60c89cf30..c11b69ca2 100644 --- a/src/app/features/settings/account-settings/services/index.ts +++ b/src/app/features/settings/account-settings/services/index.ts @@ -1 +1,2 @@ export { AccountSettingsService } from './account-settings.service'; +export { ExternalIdentityDeletionService } from './external-identity-deletion.service'; diff --git a/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts index 9b49b9506..d6f47fdff 100644 --- a/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts +++ b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts @@ -2,8 +2,6 @@ import { Store } from '@ngxs/store'; import { MockProvider } from 'ng-mocks'; -import { Mock } from 'vitest'; - import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -11,21 +9,17 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { AuthService } from '@core/services/auth.service'; import { AccountSettingsSelectors } from '@osf/features/settings/account-settings/store/account-settings.selectors'; import { ExternalIdentityStatus } from '@osf/shared/enums/external-identity-status.enum'; -import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; -import { LoaderService } from '@osf/shared/services/loader.service'; -import { ToastService } from '@osf/shared/services/toast.service'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { AuthServiceMock, AuthServiceMockType } from '@testing/providers/auth-service.mock'; import { - CustomConfirmationServiceMock, - CustomConfirmationServiceMockType, -} from '@testing/providers/custom-confirmation-provider.mock'; -import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; + ExternalIdentityDeletionServiceMock, + ExternalIdentityDeletionServiceMockType, +} from '@testing/providers/external-identity-deletion-service.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; -import { DeleteExternalIdentity, GetExternalIdentities } from '../../../account-settings/store'; +import { ExternalIdentityDeletionService } from '../../../account-settings/services'; +import { GetExternalIdentities } from '../../../account-settings/store'; import { AuthenticatedIdentityComponent } from './authenticated-identity.component'; @@ -33,9 +27,7 @@ describe('AuthenticatedIdentityComponent', () => { let component: AuthenticatedIdentityComponent; let fixture: ComponentFixture; let store: Store; - let customConfirmationServiceMock: CustomConfirmationServiceMockType; - let loaderServiceMock: LoaderServiceMock; - let toastServiceMock: ToastServiceMockType; + let externalIdentityDeletion: ExternalIdentityDeletionServiceMockType; let authServiceMock: AuthServiceMockType; const mockExternalIdentities = signal([ @@ -47,9 +39,7 @@ describe('AuthenticatedIdentityComponent', () => { ]); beforeEach(() => { - customConfirmationServiceMock = CustomConfirmationServiceMock.simple(); - loaderServiceMock = new LoaderServiceMock(); - toastServiceMock = ToastServiceMock.simple(); + externalIdentityDeletion = ExternalIdentityDeletionServiceMock.simple(); authServiceMock = AuthServiceMock.simple(); TestBed.configureTestingModule({ @@ -58,9 +48,7 @@ describe('AuthenticatedIdentityComponent', () => { provideOSFCore(), MockProvider(ENVIRONMENT, { webUrl: 'http://localhost:4200', casUrl: 'http://localhost:8080' }), MockProvider(AuthService, authServiceMock), - MockProvider(LoaderService, loaderServiceMock), - MockProvider(CustomConfirmationService, customConfirmationServiceMock), - MockProvider(ToastService, toastServiceMock), + MockProvider(ExternalIdentityDeletionService, externalIdentityDeletion), provideMockStore({ signals: [ { @@ -85,8 +73,6 @@ describe('AuthenticatedIdentityComponent', () => { it('should show existing user ORCID when present in external identities', () => { expect(component.existingOrcid()).toEqual('0001-0002-0003-0004'); expect(component.orcidUrl()).toEqual('https://orcid.org/0001-0002-0003-0004'); - component.disconnectOrcid(); - expect(customConfirmationServiceMock.confirmDelete).toHaveBeenCalled(); }); it('should show connect button when no existing ORCID is present in external identities', () => { @@ -101,29 +87,10 @@ describe('AuthenticatedIdentityComponent', () => { expect(store.dispatch).toHaveBeenCalledWith(new GetExternalIdentities()); }); - it('should delete ORCID and show success when confirmation is accepted', () => { - (store.dispatch as Mock).mockClear(); - component.disconnectOrcid(); - - const { onConfirm } = customConfirmationServiceMock.confirmDelete.mock.calls[0][0]; - onConfirm(); - - expect(loaderServiceMock.show).toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith(new DeleteExternalIdentity('ORCID')); - expect(toastServiceMock.showSuccess).toHaveBeenCalledWith( - 'settings.accountSettings.connectedIdentities.successDelete' - ); - expect(loaderServiceMock.hide).toHaveBeenCalled(); - }); - - it('should not delete ORCID when confirmation is not accepted', () => { - (store.dispatch as Mock).mockClear(); + it('should delegate ORCID disconnect to the deletion service', () => { component.disconnectOrcid(); - expect(loaderServiceMock.show).not.toHaveBeenCalled(); - expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(DeleteExternalIdentity)); - expect(toastServiceMock.showSuccess).not.toHaveBeenCalled(); - expect(loaderServiceMock.hide).not.toHaveBeenCalled(); + expect(externalIdentityDeletion.confirmAndDelete).toHaveBeenCalledWith('ORCID'); }); it('should redirect to CAS login with ORCID redirect and social tab destination', () => { diff --git a/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts index fb9bdf6fe..3cb9b108a 100644 --- a/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts +++ b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts @@ -5,23 +5,15 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Tooltip } from 'primeng/tooltip'; -import { finalize } from 'rxjs'; - import { NgOptimizedImage } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, OnInit } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { AuthService } from '@core/services/auth.service'; import { ExternalIdentityStatus } from '@osf/shared/enums/external-identity-status.enum'; -import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; -import { LoaderService } from '@osf/shared/services/loader.service'; -import { ToastService } from '@osf/shared/services/toast.service'; -import { - AccountSettingsSelectors, - DeleteExternalIdentity, - GetExternalIdentities, -} from '../../../account-settings/store'; +import { ExternalIdentityDeletionService } from '../../../account-settings/services'; +import { AccountSettingsSelectors, GetExternalIdentities } from '../../../account-settings/store'; import { ProfileSettingsTabOption } from '../../enums'; @Component({ @@ -34,9 +26,7 @@ import { ProfileSettingsTabOption } from '../../enums'; export class AuthenticatedIdentityComponent implements OnInit { private readonly authService = inject(AuthService); private readonly environment = inject(ENVIRONMENT); - private readonly customConfirmationService = inject(CustomConfirmationService); - private readonly toastService = inject(ToastService); - private readonly loaderService = inject(LoaderService); + private readonly externalIdentityDeletion = inject(ExternalIdentityDeletionService); private readonly ORCID_PROVIDER = 'ORCID'; @@ -45,7 +35,6 @@ export class AuthenticatedIdentityComponent implements OnInit { } readonly actions = createDispatchMap({ - deleteExternalIdentity: DeleteExternalIdentity, getExternalIdentities: GetExternalIdentities, }); @@ -60,18 +49,7 @@ export class AuthenticatedIdentityComponent implements OnInit { ); disconnectOrcid(): void { - this.customConfirmationService.confirmDelete({ - headerKey: 'settings.accountSettings.connectedIdentities.deleteDialog.header', - messageParams: { name: this.ORCID_PROVIDER }, - messageKey: 'settings.accountSettings.connectedIdentities.deleteDialog.message', - onConfirm: () => { - this.loaderService.show(); - this.actions - .deleteExternalIdentity(this.ORCID_PROVIDER) - .pipe(finalize(() => this.loaderService.hide())) - .subscribe(() => this.toastService.showSuccess('settings.accountSettings.connectedIdentities.successDelete')); - }, - }); + this.externalIdentityDeletion.confirmAndDelete(this.ORCID_PROVIDER); } connectOrcid(): void { diff --git a/src/app/shared/services/json-api.service.ts b/src/app/shared/services/json-api.service.ts index 0b5cc1520..9997fb335 100644 --- a/src/app/shared/services/json-api.service.ts +++ b/src/app/shared/services/json-api.service.ts @@ -80,7 +80,7 @@ export class JsonApiService { }); } - delete(url: string, body?: unknown, headers?: Record): Observable { - return this.http.delete(url, { body, headers }); + delete(url: string, body?: unknown, headers?: Record, context?: HttpContext): Observable { + return this.http.delete(url, { body, headers, context }); } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 78a04e64d..2d82d9ff8 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2552,6 +2552,11 @@ "message": "Are you sure you want to delete {{name}} identity?" }, "description": "Connected identities allow you to log in to the OSF via a third-party service. You can revoke these identifies.", + "errors": { + "contactSupport": "Unable to disconnect this identity. Please contact support if the problem persists.", + "retryLater": "Unable to revoke ORCiD access at this time. Please try again later.", + "signInViaOrcid": "We couldn't find a stored ORCiD authorization for this identity. Please sign in via ORCiD and try again." + }, "noIdentities": "You have not authorized any external services to log in to the OSF.", "successDelete": "Successfully deleted connected identities.", "title": "Connected identities" diff --git a/src/testing/providers/external-identity-deletion-service.mock.ts b/src/testing/providers/external-identity-deletion-service.mock.ts new file mode 100644 index 000000000..ab2ff975b --- /dev/null +++ b/src/testing/providers/external-identity-deletion-service.mock.ts @@ -0,0 +1,37 @@ +import { Mock, vi } from 'vitest'; + +import { ExternalIdentityDeletionService } from '@osf/features/settings/account-settings/services'; + +type ConfirmAndDeleteFn = (identityId: string) => void; + +export type ExternalIdentityDeletionServiceMockType = Pick & { + confirmAndDelete: Mock; +}; + +export class ExternalIdentityDeletionServiceMockBuilder { + private confirmAndDeleteMock: Mock = vi.fn(); + + static create(): ExternalIdentityDeletionServiceMockBuilder { + return new ExternalIdentityDeletionServiceMockBuilder(); + } + + withConfirmAndDelete(mockImpl: Mock): ExternalIdentityDeletionServiceMockBuilder { + this.confirmAndDeleteMock = mockImpl; + return this; + } + + build(): ExternalIdentityDeletionServiceMockType { + return { + confirmAndDelete: this.confirmAndDeleteMock, + } as ExternalIdentityDeletionServiceMockType; + } +} + +export const ExternalIdentityDeletionServiceMock = { + create() { + return ExternalIdentityDeletionServiceMockBuilder.create(); + }, + simple() { + return ExternalIdentityDeletionServiceMockBuilder.create().build(); + }, +}; diff --git a/src/testing/providers/json-api.service.mock.ts b/src/testing/providers/json-api.service.mock.ts index 828c7b44e..c4c39f312 100644 --- a/src/testing/providers/json-api.service.mock.ts +++ b/src/testing/providers/json-api.service.mock.ts @@ -27,7 +27,12 @@ type PatchFn = ( ) => Observable; type PutFn = (url: string, body: unknown, params?: Record) => Observable; type PutFileFn = (url: string, file: File, params?: Record) => Observable>; -type DeleteFn = (url: string, body?: unknown, headers?: Record) => Observable; +type DeleteFn = ( + url: string, + body?: unknown, + headers?: Record, + context?: HttpContext +) => Observable; export type JsonApiServiceMockType = Pick & { get: Mock;