diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 36aed9cad..3edc6c1e5 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -8,8 +8,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { GetCurrentUser, UserState } from '@core/store/user'; +import { UserEmailsState } from '@core/store/user-emails'; import { FullScreenLoaderComponent, ToastComponent } from './shared/components'; +import { TranslateServiceMock } from './shared/mocks'; import { AppComponent } from './app.component'; describe('AppComponent', () => { @@ -19,7 +21,12 @@ describe('AppComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [AppComponent, ...MockComponents(ToastComponent, FullScreenLoaderComponent)], - providers: [provideStore([UserState]), provideHttpClient(), provideHttpClientTesting()], + providers: [ + provideStore([UserState, UserEmailsState]), + provideHttpClient(), + provideHttpClientTesting(), + TranslateServiceMock, + ], }).compileComponents(); fixture = TestBed.createComponent(AppComponent); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 7b36c6e21..ccf75a496 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,12 +1,18 @@ -import { createDispatchMap } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslateService } from '@ngx-translate/core'; + +import { DialogService } from 'primeng/dynamicdialog'; import { filter } from 'rxjs/operators'; -import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; import { GetCurrentUser } from '@core/store/user'; +import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails'; +import { ConfirmEmailComponent } from '@shared/components'; import { FullScreenLoaderComponent, ToastComponent } from './shared/components'; import { MetaTagsService } from './shared/services/meta-tags.service'; @@ -17,23 +23,31 @@ import { MetaTagsService } from './shared/services/meta-tags.service'; templateUrl: './app.component.html', styleUrl: './app.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DialogService], }) export class AppComponent implements OnInit { - private destroyRef = inject(DestroyRef); + private readonly destroyRef = inject(DestroyRef); + private readonly dialogService = inject(DialogService); + private readonly router = inject(Router); + private readonly translateService = inject(TranslateService); + private readonly metaTagsService = inject(MetaTagsService); + + private readonly actions = createDispatchMap({ getCurrentUser: GetCurrentUser, getEmails: GetEmails }); - actions = createDispatchMap({ - getCurrentUser: GetCurrentUser, - }); + unverifiedEmails = select(UserEmailsSelectors.getUnverifiedEmails); - constructor( - private router: Router, - private metaTagsService: MetaTagsService - ) { + constructor() { this.setupMetaTagsCleanup(); + effect(() => { + if (this.unverifiedEmails().length) { + this.showEmailDialog(); + } + }); } ngOnInit(): void { this.actions.getCurrentUser(); + this.actions.getEmails(); } private setupMetaTagsCleanup(): void { @@ -44,4 +58,15 @@ export class AppComponent implements OnInit { ) .subscribe((event: NavigationEnd) => this.metaTagsService.clearMetaTagsIfNeeded(event.url)); } + + private showEmailDialog() { + this.dialogService.open(ConfirmEmailComponent, { + width: '448px', + focusOnShow: false, + header: this.translateService.instant('home.confirmEmail.title'), + modal: true, + closable: false, + data: this.unverifiedEmails(), + }); + } } diff --git a/src/app/core/constants/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts index 1cd9b6922..2626989c1 100644 --- a/src/app/core/constants/ngxs-states.constant.ts +++ b/src/app/core/constants/ngxs-states.constant.ts @@ -1,5 +1,6 @@ import { ProviderState } from '@core/store/provider'; import { UserState } from '@core/store/user'; +import { UserEmailsState } from '@core/store/user-emails'; import { FilesState } from '@osf/features/files/store'; import { ProjectMetadataState } from '@osf/features/project/metadata/store'; import { ProjectOverviewState } from '@osf/features/project/overview/store'; @@ -13,6 +14,7 @@ import { RegionsState } from '@shared/stores/regions'; export const STATES = [ AddonsState, UserState, + UserEmailsState, ProviderState, MyResourcesState, InstitutionsState, diff --git a/src/app/core/services/index.ts b/src/app/core/services/index.ts index dae45ef7b..45baa421e 100644 --- a/src/app/core/services/index.ts +++ b/src/app/core/services/index.ts @@ -1,3 +1,4 @@ export { AuthService } from './auth.service'; export { RequestAccessService } from './request-access.service'; export { UserService } from './user.service'; +export { UserEmailsService } from './user-emails.service'; diff --git a/src/app/core/services/user-emails.service.ts b/src/app/core/services/user-emails.service.ts new file mode 100644 index 000000000..3a55fe78c --- /dev/null +++ b/src/app/core/services/user-emails.service.ts @@ -0,0 +1,97 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { MapEmail, MapEmails } from '@osf/shared/mappers'; +import { AccountEmailModel, EmailResponseJsonApi, EmailsDataJsonApi, EmailsResponseJsonApi } from '@osf/shared/models'; +import { JsonApiService } from '@osf/shared/services'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class UserEmailsService { + private readonly jsonApiService = inject(JsonApiService); + private readonly baseUrl = `${environment.apiUrl}/users`; + + getEmails(): Observable { + const params: Record = { + page: '1', + 'page[size]': '10', + }; + + return this.jsonApiService + .get(`${this.baseUrl}/me/settings/emails/`, params) + .pipe(map((response) => MapEmails(response.data))); + } + + resendConfirmation(emailId: string): Observable { + const params: Record = { + resend_confirmation: 'true', + }; + + return this.jsonApiService + .get(`${this.baseUrl}/me/settings/emails/${emailId}/`, params) + .pipe(map((response) => MapEmail(response.data))); + } + + addEmail(userId: string, email: string): Observable { + const body = { + data: { + attributes: { + email_address: email, + }, + relationships: { + user: { + data: { + id: userId, + type: 'users', + }, + }, + }, + type: 'user_emails', + }, + }; + + return this.jsonApiService + .post(`${this.baseUrl}/${userId}/settings/emails/`, body) + .pipe(map((response) => MapEmail(response.data))); + } + + verifyEmail(emailId: string): Observable { + const body = { + data: { + id: emailId, + attributes: { + verified: true, + }, + type: 'user_emails', + }, + }; + + return this.jsonApiService + .patch(`${this.baseUrl}/me/settings/emails/${emailId}/`, body) + .pipe(map((response) => MapEmail(response))); + } + + makePrimary(emailId: string): Observable { + const body = { + data: { + id: emailId, + attributes: { + primary: true, + }, + type: 'user_emails', + }, + }; + + return this.jsonApiService + .patch(`${this.baseUrl}/me/settings/emails/${emailId}/`, body) + .pipe(map((response) => MapEmail(response))); + } + + deleteEmail(emailId: string): Observable { + return this.jsonApiService.delete(`${this.baseUrl}/me/settings/emails/${emailId}/`); + } +} diff --git a/src/app/core/services/user.service.ts b/src/app/core/services/user.service.ts index dc9d5622a..97abb9860 100644 --- a/src/app/core/services/user.service.ts +++ b/src/app/core/services/user.service.ts @@ -15,7 +15,7 @@ import { UserSettingsGetResponse, } from '@osf/shared/models'; -import { JsonApiService } from '../../shared/services/json-api.service'; +import { JsonApiService } from '../../shared/services'; import { environment } from 'src/environments/environment'; diff --git a/src/app/core/store/user-emails/index.ts b/src/app/core/store/user-emails/index.ts new file mode 100644 index 000000000..51506ad73 --- /dev/null +++ b/src/app/core/store/user-emails/index.ts @@ -0,0 +1,4 @@ +export * from './user-emails.actions'; +export * from './user-emails.model'; +export * from './user-emails.selectors'; +export * from './user-emails.state'; diff --git a/src/app/core/store/user-emails/user-emails.actions.ts b/src/app/core/store/user-emails/user-emails.actions.ts new file mode 100644 index 000000000..5bd971bd4 --- /dev/null +++ b/src/app/core/store/user-emails/user-emails.actions.ts @@ -0,0 +1,33 @@ +export class GetEmails { + static readonly type = '[UserEmails] Get Emails'; +} + +export class AddEmail { + static readonly type = '[UserEmails] Add Email'; + + constructor(public email: string) {} +} + +export class DeleteEmail { + static readonly type = '[UserEmails] Remove Email'; + + constructor(public emailId: string) {} +} + +export class ResendConfirmation { + static readonly type = '[UserEmails] Resend Confirmation'; + + constructor(public emailId: string) {} +} + +export class VerifyEmail { + static readonly type = '[UserEmails] Verify Email'; + + constructor(public emailId: string) {} +} + +export class MakePrimary { + static readonly type = '[UserEmails] Make Primary'; + + constructor(public emailId: string) {} +} diff --git a/src/app/core/store/user-emails/user-emails.model.ts b/src/app/core/store/user-emails/user-emails.model.ts new file mode 100644 index 000000000..fa894f3dc --- /dev/null +++ b/src/app/core/store/user-emails/user-emails.model.ts @@ -0,0 +1,14 @@ +import { AccountEmailModel, AsyncStateModel } from '@shared/models'; + +export interface UserEmailsStateModel { + emails: AsyncStateModel; +} + +export const USER_EMAILS_STATE_DEFAULTS: UserEmailsStateModel = { + emails: { + data: [], + isLoading: false, + error: null, + isSubmitting: false, + }, +}; diff --git a/src/app/core/store/user-emails/user-emails.selectors.ts b/src/app/core/store/user-emails/user-emails.selectors.ts new file mode 100644 index 000000000..7e1f0316d --- /dev/null +++ b/src/app/core/store/user-emails/user-emails.selectors.ts @@ -0,0 +1,28 @@ +import { Selector } from '@ngxs/store'; + +import { AccountEmailModel } from '@osf/shared/models'; + +import { UserEmailsStateModel } from './user-emails.model'; +import { UserEmailsState } from './user-emails.state'; + +export class UserEmailsSelectors { + @Selector([UserEmailsState]) + static getEmails(state: UserEmailsStateModel): AccountEmailModel[] { + return state.emails.data; + } + + @Selector([UserEmailsState]) + static isEmailsLoading(state: UserEmailsStateModel): boolean { + return state.emails.isLoading; + } + + @Selector([UserEmailsState]) + static isEmailsSubmitting(state: UserEmailsStateModel): boolean | undefined { + return state.emails.isSubmitting; + } + + @Selector([UserEmailsState]) + static getUnverifiedEmails(state: UserEmailsStateModel): AccountEmailModel[] { + return state.emails.data.filter((email) => email.confirmed && !email.verified); + } +} diff --git a/src/app/core/store/user-emails/user-emails.state.ts b/src/app/core/store/user-emails/user-emails.state.ts new file mode 100644 index 000000000..734f6ac82 --- /dev/null +++ b/src/app/core/store/user-emails/user-emails.state.ts @@ -0,0 +1,161 @@ +import { Action, State, StateContext, Store } from '@ngxs/store'; + +import { catchError, tap, throwError } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { UserEmailsService } from '@core/services'; +import { handleSectionError } from '@osf/shared/helpers'; + +import { UserSelectors } from '../user/user.selectors'; + +import { AddEmail, DeleteEmail, GetEmails, MakePrimary, ResendConfirmation, VerifyEmail } from './user-emails.actions'; +import { USER_EMAILS_STATE_DEFAULTS, UserEmailsStateModel } from './user-emails.model'; + +@Injectable() +@State({ + name: 'userEmails', + defaults: USER_EMAILS_STATE_DEFAULTS, +}) +export class UserEmailsState { + private readonly userEmailsService = inject(UserEmailsService); + private readonly store = inject(Store); + + @Action(GetEmails) + getEmails(ctx: StateContext) { + ctx.patchState({ + emails: { + ...ctx.getState().emails, + isLoading: true, + error: null, + }, + }); + + return this.userEmailsService.getEmails().pipe( + tap((emails) => + ctx.patchState({ + emails: { + data: emails, + isLoading: false, + error: null, + isSubmitting: false, + }, + }) + ), + catchError((error) => handleSectionError(ctx, 'emails', error)) + ); + } + + @Action(AddEmail) + addEmail(ctx: StateContext, action: AddEmail) { + const currentUser = this.store.selectSnapshot(UserSelectors.getCurrentUser); + + if (!currentUser?.id) { + return; + } + + ctx.patchState({ + emails: { + ...ctx.getState().emails, + isSubmitting: true, + error: null, + }, + }); + + return this.userEmailsService.addEmail(currentUser.id, action.email).pipe( + tap((newEmail) => { + const currentEmails = ctx.getState().emails.data; + ctx.patchState({ + emails: { + data: [...currentEmails, newEmail], + isLoading: false, + error: null, + isSubmitting: false, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'emails', error)) + ); + } + + @Action(DeleteEmail) + deleteEmail(ctx: StateContext, action: DeleteEmail) { + ctx.patchState({ + emails: { + ...ctx.getState().emails, + isSubmitting: true, + error: null, + }, + }); + + return this.userEmailsService.deleteEmail(action.emailId).pipe( + tap(() => { + const currentEmails = ctx.getState().emails.data; + const updatedEmails = currentEmails.filter((email) => email.id !== action.emailId); + ctx.patchState({ + emails: { + data: updatedEmails, + isLoading: false, + error: null, + isSubmitting: false, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'emails', error)) + ); + } + + @Action(VerifyEmail) + verifyEmail(ctx: StateContext, action: VerifyEmail) { + ctx.patchState({ + emails: { + ...ctx.getState().emails, + isSubmitting: true, + error: null, + }, + }); + + return this.userEmailsService.verifyEmail(action.emailId).pipe( + tap((verifiedEmail) => { + const currentEmails = ctx.getState().emails.data; + const updatedEmails = currentEmails.map((email) => (email.id === action.emailId ? verifiedEmail : email)); + ctx.patchState({ + emails: { + data: updatedEmails, + isLoading: false, + error: null, + isSubmitting: false, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'emails', error)) + ); + } + + @Action(ResendConfirmation) + resendConfirmation(ctx: StateContext, action: ResendConfirmation) { + return this.userEmailsService + .resendConfirmation(action.emailId) + .pipe(catchError((error) => throwError(() => error))); + } + + @Action(MakePrimary) + makePrimary(ctx: StateContext, action: MakePrimary) { + ctx.patchState({ + emails: { + ...ctx.getState().emails, + isSubmitting: true, + error: null, + }, + }); + + return this.userEmailsService.makePrimary(action.emailId).pipe( + tap((email) => { + if (email.verified) { + ctx.dispatch(GetEmails); + } + }), + catchError((error) => handleSectionError(ctx, 'emails', error)) + ); + } +} diff --git a/src/app/core/store/user/user.model.ts b/src/app/core/store/user/user.model.ts index 8c73a016c..bdae49d6d 100644 --- a/src/app/core/store/user/user.model.ts +++ b/src/app/core/store/user/user.model.ts @@ -16,7 +16,7 @@ export const USER_STATE_INITIAL: UserStateModel = { data: null, isLoading: false, isSubmitting: false, - error: '', + error: null, }, activeFlags: [], }; diff --git a/src/app/core/store/user/user.state.ts b/src/app/core/store/user/user.state.ts index 70d30969d..7705acd10 100644 --- a/src/app/core/store/user/user.state.ts +++ b/src/app/core/store/user/user.state.ts @@ -232,6 +232,7 @@ export class UserState { }) ); } + @Action(SetUserAsModerator) setUserAsModerator(ctx: StateContext) { const state = ctx.getState(); diff --git a/src/app/features/home/components/confirm-email/confirm-email.component.spec.ts b/src/app/features/home/components/confirm-email/confirm-email.component.spec.ts deleted file mode 100644 index 9783770b5..000000000 --- a/src/app/features/home/components/confirm-email/confirm-email.component.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { provideStore } from '@ngxs/store'; - -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { MockPipe, MockProvider, MockProviders } from 'ng-mocks'; - -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AccountSettingsState } from '@osf/features/settings/account-settings/store/account-settings.state'; - -import { ConfirmEmailComponent } from './confirm-email.component'; - -describe('ConfirmEmailComponent', () => { - let component: ConfirmEmailComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ConfirmEmailComponent, MockPipe(TranslatePipe)], - providers: [ - MockProvider(TranslateService), - MockProviders(DynamicDialogRef), - MockProvider(DynamicDialogConfig, { data: { emailAddress: 'test@email.com' } }), - provideStore([AccountSettingsState]), - provideHttpClient(), - provideHttpClientTesting(), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(ConfirmEmailComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/home/components/confirm-email/confirm-email.component.ts b/src/app/features/home/components/confirm-email/confirm-email.component.ts deleted file mode 100644 index 366a156b0..000000000 --- a/src/app/features/home/components/confirm-email/confirm-email.component.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { TranslatePipe } from '@ngx-translate/core'; - -import { Button } from 'primeng/button'; -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { finalize } from 'rxjs'; - -import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; - -import { AccountSettingsService } from '@osf/features/settings/account-settings/services'; -import { LoadingSpinnerComponent } from '@osf/shared/components'; - -@Component({ - selector: 'osf-confirm-email', - imports: [Button, FormsModule, TranslatePipe, LoadingSpinnerComponent], - templateUrl: './confirm-email.component.html', - styleUrl: './confirm-email.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ConfirmEmailComponent { - readonly dialogRef = inject(DynamicDialogRef); - readonly config = inject(DynamicDialogConfig); - - private readonly router = inject(Router); - private readonly accountSettingsService = inject(AccountSettingsService); - private readonly destroyRef = inject(DestroyRef); - - verifyingEmail = signal(false); - - closeDialog() { - this.router.navigate(['/']); - this.dialogRef.close(); - } - - verifyEmail() { - this.verifyingEmail.set(true); - this.accountSettingsService - .confirmEmail(this.config.data.userId, this.config.data.token) - .pipe( - takeUntilDestroyed(this.destroyRef), - finalize(() => this.verifyingEmail.set(false)) - ) - .subscribe({ - next: () => { - this.router.navigate(['/settings/account-settings']); - this.dialogRef.close(); - }, - error: () => this.closeDialog(), - }); - } -} diff --git a/src/app/features/home/components/index.ts b/src/app/features/home/components/index.ts deleted file mode 100644 index 8f1f066a0..000000000 --- a/src/app/features/home/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ConfirmEmailComponent } from './confirm-email/confirm-email.component'; diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts index 5fe7dd9ca..989fc28a9 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.ts @@ -7,7 +7,7 @@ import { Button } from 'primeng/button'; import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; import { TablePageEvent } from 'primeng/table'; -import { debounceTime, distinctUntilChanged, take } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs'; import { Component, computed, DestroyRef, effect, inject, OnInit, signal } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; @@ -15,7 +15,6 @@ import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { CreateProjectDialogComponent } from '@osf/features/my-projects/components'; -import { AccountSettingsService } from '@osf/features/settings/account-settings/services'; import { IconComponent, MyProjectsTableComponent, SubHeaderComponent } from '@osf/shared/components'; import { MY_PROJECTS_TABLE_PARAMS } from '@osf/shared/constants'; import { SortOrder } from '@osf/shared/enums'; @@ -23,8 +22,6 @@ import { IS_MEDIUM } from '@osf/shared/helpers'; import { MyResourcesItem, MyResourcesSearchFilters, TableParameters } from '@osf/shared/models'; import { ClearMyResources, GetMyProjects, MyResourcesSelectors } from '@osf/shared/stores'; -import { ConfirmEmailComponent } from '../../components'; - @Component({ selector: 'osf-dashboard', imports: [RouterLink, Button, SubHeaderComponent, MyProjectsTableComponent, IconComponent, TranslatePipe], @@ -38,7 +35,6 @@ export class DashboardComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly translateService = inject(TranslateService); private readonly dialogService = inject(DialogService); - private readonly accountSettingsService = inject(AccountSettingsService); readonly isMedium = toSignal(inject(IS_MEDIUM)); @@ -70,40 +66,6 @@ export class DashboardComponent implements OnInit { ngOnInit() { this.setupQueryParamsSubscription(); - - this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { - const userId = params['userId']; - const token = params['token']; - - if (userId && token) { - this.accountSettingsService - .getEmail(token, userId) - .pipe(take(1)) - .subscribe((email) => { - this.emailAddress = email.emailAddress; - this.addAlternateEmail(token); - }); - } - }); - } - - addAlternateEmail(token: string) { - this.translateService.get('home.confirmEmail.title').subscribe((title) => { - this.dialogRef = this.dialogService.open(ConfirmEmailComponent, { - width: '448px', - focusOnShow: false, - header: title, - closeOnEscape: true, - modal: true, - closable: true, - data: { - emailAddress: this.emailAddress, - userId: this.route.snapshot.params['userId'], - emailId: this.route.snapshot.params['emailId'], - token: token, - }, - }); - }); } setupQueryParamsSubscription(): void { diff --git a/src/app/features/settings/account-settings/account-settings.component.spec.ts b/src/app/features/settings/account-settings/account-settings.component.spec.ts index 3b146f638..05f487342 100644 --- a/src/app/features/settings/account-settings/account-settings.component.spec.ts +++ b/src/app/features/settings/account-settings/account-settings.component.spec.ts @@ -37,9 +37,6 @@ describe('AccountSettingsComponent', () => { case UserSelectors.getCurrentUser: return () => MOCK_USER; - case AccountSettingsSelectors.getEmails: - return () => []; - case AccountSettingsSelectors.getAccountSettings: return () => null; diff --git a/src/app/features/settings/account-settings/account-settings.component.ts b/src/app/features/settings/account-settings/account-settings.component.ts index dde2087b6..1ce4fdb36 100644 --- a/src/app/features/settings/account-settings/account-settings.component.ts +++ b/src/app/features/settings/account-settings/account-settings.component.ts @@ -7,6 +7,7 @@ import { DialogService } from 'primeng/dynamicdialog'; import { ChangeDetectionStrategy, Component, effect } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; +import { GetEmails } from '@core/store/user-emails'; import { UserSelectors } from '@osf/core/store/user'; import { SubHeaderComponent } from '@osf/shared/components'; @@ -20,7 +21,7 @@ import { ShareIndexingComponent, TwoFactorAuthComponent, } from './components'; -import { GetAccountSettings, GetEmails, GetExternalIdentities, GetRegions, GetUserInstitutions } from './store'; +import { GetAccountSettings, GetExternalIdentities, GetRegions, GetUserInstitutions } from './store'; @Component({ selector: 'osf-account-settings', diff --git a/src/app/features/settings/account-settings/components/add-email/add-email.component.spec.ts b/src/app/features/settings/account-settings/components/add-email/add-email.component.spec.ts index 28df66c73..0469e21d5 100644 --- a/src/app/features/settings/account-settings/components/add-email/add-email.component.spec.ts +++ b/src/app/features/settings/account-settings/components/add-email/add-email.component.spec.ts @@ -9,6 +9,7 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UserEmailsState } from '@core/store/user-emails'; import { TranslateServiceMock } from '@shared/mocks'; import { ToastService } from '@shared/services'; @@ -25,7 +26,7 @@ describe('AddEmailComponent', () => { await TestBed.configureTestingModule({ imports: [AddEmailComponent, MockPipe(TranslatePipe)], providers: [ - provideStore([AccountSettingsState]), + provideStore([AccountSettingsState, UserEmailsState]), MockProviders(DynamicDialogRef, ToastService), TranslateServiceMock, provideHttpClient(), diff --git a/src/app/features/settings/account-settings/components/add-email/add-email.component.ts b/src/app/features/settings/account-settings/components/add-email/add-email.component.ts index 8acfd562c..8bd844f3f 100644 --- a/src/app/features/settings/account-settings/components/add-email/add-email.component.ts +++ b/src/app/features/settings/account-settings/components/add-email/add-email.component.ts @@ -8,13 +8,12 @@ import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +import { AddEmail, UserEmailsSelectors } from '@core/store/user-emails'; import { TextInputComponent } from '@osf/shared/components'; import { InputLimits } from '@osf/shared/constants'; import { CustomValidators } from '@osf/shared/helpers'; import { ToastService } from '@osf/shared/services'; -import { AccountSettingsSelectors, AddEmail } from '../../store'; - @Component({ selector: 'osf-confirmation-sent-dialog', imports: [TextInputComponent, ReactiveFormsModule, Button, TranslatePipe], @@ -28,7 +27,7 @@ export class AddEmailComponent { private readonly action = createDispatchMap({ addEmail: AddEmail }); private readonly toastService = inject(ToastService); - isSubmitting = select(AccountSettingsSelectors.isEmailsSubmitting); + isSubmitting = select(UserEmailsSelectors.isEmailsSubmitting); protected readonly emailControl = new FormControl('', { nonNullable: true, diff --git a/src/app/features/settings/account-settings/components/change-password/change-password.component.html b/src/app/features/settings/account-settings/components/change-password/change-password.component.html index 29708f6c6..76a42e982 100644 --- a/src/app/features/settings/account-settings/components/change-password/change-password.component.html +++ b/src/app/features/settings/account-settings/components/change-password/change-password.component.html @@ -133,7 +133,7 @@

{{ 'settings.accountSettings.changePassword.title' | translate }}

diff --git a/src/app/features/settings/account-settings/components/change-password/change-password.component.ts b/src/app/features/settings/account-settings/components/change-password/change-password.component.ts index 9cfc1fdaf..c4ac24618 100644 --- a/src/app/features/settings/account-settings/components/change-password/change-password.component.ts +++ b/src/app/features/settings/account-settings/components/change-password/change-password.component.ts @@ -19,6 +19,7 @@ import { Validators, } from '@angular/forms'; +import { AuthService } from '@core/services'; import { CustomValidators, FormValidationHelper } from '@osf/shared/helpers'; import { LoaderService, ToastService } from '@osf/shared/services'; @@ -37,6 +38,7 @@ export class ChangePasswordComponent implements OnInit { private readonly loaderService = inject(LoaderService); private readonly toastService = inject(ToastService); private readonly destroyRef = inject(DestroyRef); + private readonly authService = inject(AuthService); readonly passwordForm: AccountSettingsPasswordForm = new FormGroup({ [AccountSettingsPasswordFormControls.OldPassword]: new FormControl('', { @@ -57,10 +59,10 @@ export class ChangePasswordComponent implements OnInit { }), }); - protected readonly AccountSettingsPasswordFormControls = AccountSettingsPasswordFormControls; - protected readonly FormValidationHelper = FormValidationHelper; + readonly AccountSettingsPasswordFormControls = AccountSettingsPasswordFormControls; + readonly FormValidationHelper = FormValidationHelper; - protected errorMessage = signal(''); + errorMessage = signal(''); ngOnInit(): void { this.setupFormValidation(); @@ -101,11 +103,11 @@ export class ChangePasswordComponent implements OnInit { .subscribe(() => this.passwordForm.updateValueAndValidity()); } - protected getFormControl(controlName: string): AbstractControl | null { + getFormControl(controlName: string): AbstractControl | null { return FormValidationHelper.getFormControl(this.passwordForm, controlName); } - protected getFormErrors(): Record { + getFormErrors(): Record { const errors: Record = {}; if (this.passwordForm.errors?.['sameAsOldPassword']) { @@ -141,6 +143,7 @@ export class ChangePasswordComponent implements OnInit { this.loaderService.hide(); this.toastService.showSuccess('settings.accountSettings.changePassword.messages.success'); + this.authService.logout(); }, error: (error: HttpErrorResponse) => { if (error.error?.errors?.[0]?.detail) { diff --git a/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.spec.ts b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.spec.ts index 9dec818c8..caec1fda8 100644 --- a/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.spec.ts +++ b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.spec.ts @@ -12,6 +12,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { DestroyRef } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UserEmailsState } from '@core/store/user-emails'; import { SetCurrentUser, UserState } from '@osf/core/store/user'; import { AddEmailComponent, ConfirmationSentDialogComponent } from '@osf/features/settings/account-settings/components'; import { MOCK_USER, MockCustomConfirmationServiceProvider } from '@shared/mocks'; @@ -42,7 +43,7 @@ describe('ConnectedEmailsComponent', () => { await TestBed.configureTestingModule({ imports: [ConnectedEmailsComponent, MockPipe(TranslatePipe)], providers: [ - provideStore([AccountSettingsState, UserState]), + provideStore([AccountSettingsState, UserState, UserEmailsState]), provideHttpClient(), provideHttpClientTesting(), MockProviders(DialogService, TranslateService, DestroyRef, LoaderService, ToastService), diff --git a/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.ts b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.ts index 0b6d52e0f..e10027e58 100644 --- a/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.ts +++ b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.ts @@ -12,13 +12,13 @@ import { filter, finalize } from 'rxjs'; import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { DeleteEmail, MakePrimary, ResendConfirmation, UserEmailsSelectors } from '@core/store/user-emails'; import { UserSelectors } from '@osf/core/store/user'; import { ReadonlyInputComponent } from '@osf/shared/components'; import { IS_SMALL } from '@osf/shared/helpers'; import { CustomConfirmationService, LoaderService, ToastService } from '@osf/shared/services'; import { AccountEmail } from '../../models'; -import { AccountSettingsSelectors, DeleteEmail, MakePrimary, ResendConfirmation } from '../../store'; import { ConfirmationSentDialogComponent } from '../confirmation-sent-dialog/confirmation-sent-dialog.component'; import { AddEmailComponent } from '../'; @@ -40,8 +40,8 @@ export class ConnectedEmailsComponent { private readonly toastService = inject(ToastService); protected readonly currentUser = select(UserSelectors.getCurrentUser); - protected readonly emails = select(AccountSettingsSelectors.getEmails); - protected readonly isEmailsLoading = select(AccountSettingsSelectors.isEmailsLoading); + protected readonly emails = select(UserEmailsSelectors.getEmails); + protected readonly isEmailsLoading = select(UserEmailsSelectors.isEmailsLoading); private readonly actions = createDispatchMap({ resendConfirmation: ResendConfirmation, @@ -96,7 +96,7 @@ export class ConnectedEmailsComponent { this.loaderService.show(); this.actions - .resendConfirmation(email.id, this.currentUser()!.id) + .resendConfirmation(email.id) .pipe( finalize(() => this.loaderService.hide()), takeUntilDestroyed(this.destroyRef) diff --git a/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.spec.ts b/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.spec.ts index b6b4c7415..cedaf2e9b 100644 --- a/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.spec.ts +++ b/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.spec.ts @@ -12,11 +12,11 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; -import { AccountSettingsService } from '@osf/features/settings/account-settings/services'; -import { AccountSettingsSelectors } from '@osf/features/settings/account-settings/store'; -import { MOCK_STORE } from '@shared/mocks'; -import { ToastService } from '@shared/services'; +import { MOCK_STORE } from '@osf/shared/mocks'; +import { ToastService } from '@osf/shared/services'; +import { AccountSettingsService } from '../../services'; +import { AccountSettingsSelectors } from '../../store'; import { CancelDeactivationComponent } from '../cancel-deactivation/cancel-deactivation.component'; import { DeactivationWarningComponent } from '../deactivation-warning/deactivation-warning.component'; diff --git a/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.ts b/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.ts index 3fd742d32..622f9dc26 100644 --- a/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.ts +++ b/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.ts @@ -35,7 +35,7 @@ export class DeactivateAccountComponent { deactivateAccount: DeactivateAccount, }); - protected accountSettings = select(AccountSettingsSelectors.getAccountSettings); + accountSettings = select(AccountSettingsSelectors.getAccountSettings); deactivateAccount() { this.dialogService @@ -51,11 +51,10 @@ export class DeactivateAccountComponent { .subscribe(() => { this.loaderService.show(); - // [NS] Hidden to avoid issues with development - // this.action.deactivateAccount().subscribe(() => { - this.toastService.showSuccess('settings.accountSettings.deactivateAccount.successDeactivation'); - this.loaderService.hide(); - // }); + this.action.deactivateAccount().subscribe(() => { + this.toastService.showSuccess('settings.accountSettings.deactivateAccount.successDeactivation'); + this.loaderService.hide(); + }); }); } diff --git a/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.ts b/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.ts index 07e748f89..87f0df057 100644 --- a/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.ts +++ b/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.ts @@ -29,9 +29,9 @@ export class DefaultStorageLocationComponent { private readonly loaderService = inject(LoaderService); private readonly toastService = inject(ToastService); - protected readonly currentUser = select(UserSelectors.getCurrentUser); - protected readonly regions = select(AccountSettingsSelectors.getRegions); - protected selectedRegion = signal(undefined); + readonly currentUser = select(UserSelectors.getCurrentUser); + readonly regions = select(AccountSettingsSelectors.getRegions); + selectedRegion = signal(undefined); constructor() { effect(() => { diff --git a/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.ts b/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.ts index 4fe85b842..dd5383740 100644 --- a/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.ts +++ b/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.ts @@ -29,7 +29,7 @@ export class ShareIndexingComponent { private readonly toastService = inject(ToastService); private readonly indexing = select(UserSelectors.getShareIndexing); - protected readonly currentUser = select(UserSelectors.getCurrentUser); + readonly currentUser = select(UserSelectors.getCurrentUser); selectedOption = this.indexing(); diff --git a/src/app/features/settings/account-settings/mappers/emails.mapper.ts b/src/app/features/settings/account-settings/mappers/emails.mapper.ts deleted file mode 100644 index e4b4c8207..000000000 --- a/src/app/features/settings/account-settings/mappers/emails.mapper.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ApiData } from '@osf/shared/models'; - -import { AccountEmail, AccountEmailResponseJsonApi } from '../models'; - -export function MapEmails(emails: ApiData[]): AccountEmail[] { - const accountEmails: AccountEmail[] = []; - emails.forEach((email) => { - accountEmails.push(MapEmail(email)); - }); - return accountEmails; -} - -export function MapEmail(email: ApiData): AccountEmail { - return { - id: email.id, - emailAddress: email.attributes.email_address, - confirmed: email.attributes.confirmed, - verified: email.attributes.verified, - primary: email.attributes.primary, - isMerge: email.attributes.is_merge, - }; -} diff --git a/src/app/features/settings/account-settings/mappers/index.ts b/src/app/features/settings/account-settings/mappers/index.ts index 95afda414..8c3358f1a 100644 --- a/src/app/features/settings/account-settings/mappers/index.ts +++ b/src/app/features/settings/account-settings/mappers/index.ts @@ -1,4 +1,3 @@ export * from './account-settings.mapper'; -export * from './emails.mapper'; export * from './external-identities.mapper'; export * from './regions.mapper'; diff --git a/src/app/features/settings/account-settings/models/responses/get-email-response.model.ts b/src/app/features/settings/account-settings/models/responses/get-email-response.model.ts deleted file mode 100644 index 2bbf1f39a..000000000 --- a/src/app/features/settings/account-settings/models/responses/get-email-response.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ApiData, JsonApiResponse } from '@osf/shared/models'; - -import { AccountEmailResponseJsonApi } from './list-emails.model'; - -export type GetEmailResponseJsonApi = JsonApiResponse, null>; diff --git a/src/app/features/settings/account-settings/models/responses/index.ts b/src/app/features/settings/account-settings/models/responses/index.ts index 2c5783873..8cfd8da94 100644 --- a/src/app/features/settings/account-settings/models/responses/index.ts +++ b/src/app/features/settings/account-settings/models/responses/index.ts @@ -1,5 +1,3 @@ export * from './get-account-settings-response.model'; -export * from './get-email-response.model'; export * from './get-regions-response.model'; -export * from './list-emails.model'; export * from './list-identities-response.model'; diff --git a/src/app/features/settings/account-settings/models/responses/list-emails.model.ts b/src/app/features/settings/account-settings/models/responses/list-emails.model.ts deleted file mode 100644 index a414e7bd8..000000000 --- a/src/app/features/settings/account-settings/models/responses/list-emails.model.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ApiData, JsonApiResponse } from '@osf/shared/models'; - -export type ListEmailsResponseJsonApi = JsonApiResponse[], null>; - -export interface AccountEmailResponseJsonApi { - email_address: string; - confirmed: boolean; - verified: boolean; - primary: boolean; - is_merge: boolean; -} 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 ad78b2abb..35321259a 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,24 +1,17 @@ -import { select } from '@ngxs/store'; - import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; -import { UserSelectors } from '@osf/core/store/user'; import { UserMapper } from '@osf/shared/mappers'; -import { ApiData, IdName, JsonApiResponse, User, UserGetResponse } from '@osf/shared/models'; +import { IdName, JsonApiResponse, User, UserGetResponse } from '@osf/shared/models'; import { JsonApiService } from '@osf/shared/services'; -import { MapAccountSettings, MapEmail, MapEmails, MapExternalIdentities, MapRegions } from '../mappers'; +import { MapAccountSettings, MapExternalIdentities, MapRegions } from '../mappers'; import { - AccountEmail, - AccountEmailResponseJsonApi, AccountSettings, ExternalIdentity, GetAccountSettingsResponseJsonApi, - GetEmailResponseJsonApi, GetRegionsResponseJsonApi, - ListEmailsResponseJsonApi, ListIdentitiesResponseJsonApi, } from '../models'; @@ -29,116 +22,6 @@ import { environment } from 'src/environments/environment'; }) export class AccountSettingsService { private readonly jsonApiService = inject(JsonApiService); - private readonly currentUser = select(UserSelectors.getCurrentUser); - - getEmails(): Observable { - const params: Record = { - page: '1', - 'page[size]': '10', - }; - - return this.jsonApiService - .get(`${environment.apiUrl}/users/${this.currentUser()?.id}/settings/emails`, params) - .pipe(map((response) => MapEmails(response.data))); - } - - getEmail( - emailId: string, - userId: string, - params: Record | undefined = undefined - ): Observable { - return this.jsonApiService - .get(`${environment.apiUrl}/users/${userId}/settings/emails/${emailId}`, params) - .pipe(map((response) => MapEmail(response.data))); - } - - resendConfirmation(emailId: string, userId: string): Observable { - const params: Record = { - resend_confirmation: 'true', - }; - - return this.getEmail(emailId, userId, params); - } - - addEmail(email: string): Observable { - const body = { - data: { - attributes: { - email_address: email, - }, - relationships: { - user: { - data: { - id: this.currentUser()?.id, - type: 'users', - }, - }, - }, - type: 'user_emails', - }, - }; - - return this.jsonApiService - .post< - JsonApiResponse, null> - >(`${environment.apiUrl}/users/${this.currentUser()?.id}/settings/emails/`, body) - .pipe(map((response) => MapEmail(response.data))); - } - - deleteEmail(emailId: string): Observable { - return this.jsonApiService.delete( - `${environment.apiUrl}/users/${this.currentUser()?.id}/settings/emails/${emailId}` - ); - } - - confirmEmail(userId: string, token: string): Observable { - const body = { - data: { - attributes: { - uid: userId, - token: token, - destination: 'doesnotmatter', - }, - }, - }; - return this.jsonApiService.post(`${environment.apiUrl}/users/${userId}/confirm/`, body); - } - - verifyEmail(userId: string, emailId: string): Observable { - const body = { - data: { - id: emailId, - attributes: { - verified: true, - }, - type: 'user_emails', - }, - }; - - return this.jsonApiService - .patch< - ApiData - >(`${environment.apiUrl}/users/${userId}/settings/emails/${emailId}/`, body) - .pipe(map((response) => MapEmail(response))); - } - - makePrimary(emailId: string): Observable { - const body = { - data: { - id: emailId, - attributes: { - primary: true, - }, - type: 'user_emails', - }, - }; - - return this.jsonApiService - .patch< - ApiData - >(`${environment.apiUrl}/users/${this.currentUser()?.id}/settings/emails/${emailId}/`, body) - .pipe(map((response) => MapEmail(response))); - } getRegions(): Observable { return this.jsonApiService @@ -146,10 +29,10 @@ export class AccountSettingsService { .pipe(map((response) => MapRegions(response.data))); } - updateLocation(locationId: string): Observable { + updateLocation(userId: string, locationId: string): Observable { const body = { data: { - id: this.currentUser()?.id, + id: userId, attributes: {}, relationships: { default_region: { @@ -164,14 +47,14 @@ export class AccountSettingsService { }; return this.jsonApiService - .patch(`${environment.apiUrl}/users/${this.currentUser()?.id}`, body) + .patch(`${environment.apiUrl}/users/${userId}/`, body) .pipe(map((user) => UserMapper.fromUserGetResponse(user))); } - updateIndexing(allowIndexing: boolean): Observable { + updateIndexing(userId: string, allowIndexing: boolean): Observable { const body = { data: { - id: this.currentUser()?.id, + id: userId, attributes: { allow_indexing: allowIndexing, }, @@ -181,7 +64,7 @@ export class AccountSettingsService { }; return this.jsonApiService - .patch(`${environment.apiUrl}/users/${this.currentUser()?.id}`, body) + .patch(`${environment.apiUrl}/users/${userId}/`, body) .pipe(map((user) => UserMapper.fromUserGetResponse(user))); } @@ -196,7 +79,7 @@ export class AccountSettingsService { }, }; - return this.jsonApiService.post(`${environment.apiUrl}/users/${this.currentUser()?.id}/settings/password`, body); + return this.jsonApiService.post(`${environment.apiUrl}/users/me/settings/password/`, body); } getExternalIdentities(): Observable { @@ -211,29 +94,26 @@ export class AccountSettingsService { } deleteExternalIdentity(id: string): Observable { - return this.jsonApiService.delete(`${environment.apiUrl}/users/me/settings/identities/${id}`); + return this.jsonApiService.delete(`${environment.apiUrl}/users/me/settings/identities/${id}/`); } getSettings(): Observable { return this.jsonApiService - .get< - JsonApiResponse - >(`${environment.apiUrl}/users/${this.currentUser()?.id}/settings`) + .get>(`${environment.apiUrl}/users/me/settings/`) .pipe(map((response) => MapAccountSettings(response.data))); } - updateSettings(settings: Record): Observable { + updateSettings(userId: string, settings: Record): Observable { const body = { data: { - id: this.currentUser()?.id, + id: userId, attributes: settings, - relationships: {}, type: 'user_settings', }, }; return this.jsonApiService - .patch(`${environment.apiUrl}/users/${this.currentUser()?.id}/settings`, body) + .patch(`${environment.apiUrl}/users/${userId}/settings`, body) .pipe(map((response) => MapAccountSettings(response))); } } diff --git a/src/app/features/settings/account-settings/store/account-settings.actions.ts b/src/app/features/settings/account-settings/store/account-settings.actions.ts index 889a75203..91722ee51 100644 --- a/src/app/features/settings/account-settings/store/account-settings.actions.ts +++ b/src/app/features/settings/account-settings/store/account-settings.actions.ts @@ -1,43 +1,3 @@ -export class GetEmails { - static readonly type = '[AccountSettings] Get Emails'; -} - -export class AddEmail { - static readonly type = '[AccountSettings] Add Email'; - - constructor(public email: string) {} -} - -export class DeleteEmail { - static readonly type = '[AccountSettings] Remove Email'; - - constructor(public email: string) {} -} - -export class ResendConfirmation { - static readonly type = '[AccountSettings] Resend Confirmation'; - - constructor( - public emailId: string, - public userId: string - ) {} -} - -export class VerifyEmail { - static readonly type = '[AccountSettings] Verify Email'; - - constructor( - public userId: string, - public emailId: string - ) {} -} - -export class MakePrimary { - static readonly type = '[AccountSettings] Make Primary'; - - constructor(public emailId: string) {} -} - export class GetRegions { static readonly type = '[AccountSettings] Get Regions'; } diff --git a/src/app/features/settings/account-settings/store/account-settings.model.ts b/src/app/features/settings/account-settings/store/account-settings.model.ts index 6dc3b7a81..fd9409d2b 100644 --- a/src/app/features/settings/account-settings/store/account-settings.model.ts +++ b/src/app/features/settings/account-settings/store/account-settings.model.ts @@ -1,9 +1,8 @@ -import { AsyncStateModel, IdName, Institution } from '@shared/models'; +import { IdName, Institution } from '@shared/models'; -import { AccountEmail, AccountSettings, ExternalIdentity } from '../models'; +import { AccountSettings, ExternalIdentity } from '../models'; export interface AccountSettingsStateModel { - emails: AsyncStateModel; regions: IdName[]; externalIdentities: ExternalIdentity[]; accountSettings: AccountSettings; @@ -11,12 +10,6 @@ export interface AccountSettingsStateModel { } export const ACCOUNT_SETTINGS_STATE_DEFAULTS: AccountSettingsStateModel = { - emails: { - data: [], - isLoading: false, - error: null, - isSubmitting: false, - }, regions: [], externalIdentities: [], accountSettings: { diff --git a/src/app/features/settings/account-settings/store/account-settings.selectors.ts b/src/app/features/settings/account-settings/store/account-settings.selectors.ts index aa58509a3..bc19d6481 100644 --- a/src/app/features/settings/account-settings/store/account-settings.selectors.ts +++ b/src/app/features/settings/account-settings/store/account-settings.selectors.ts @@ -2,27 +2,12 @@ import { Selector } from '@ngxs/store'; import { IdName, Institution } from '@shared/models'; -import { AccountEmail, AccountSettings, ExternalIdentity } from '../models'; +import { AccountSettings, ExternalIdentity } from '../models'; import { AccountSettingsStateModel } from './account-settings.model'; import { AccountSettingsState } from './account-settings.state'; export class AccountSettingsSelectors { - @Selector([AccountSettingsState]) - static getEmails(state: AccountSettingsStateModel): AccountEmail[] { - return state.emails.data; - } - - @Selector([AccountSettingsState]) - static isEmailsLoading(state: AccountSettingsStateModel): boolean { - return state.emails.isLoading; - } - - @Selector([AccountSettingsState]) - static isEmailsSubmitting(state: AccountSettingsStateModel): boolean | undefined { - return state.emails.isSubmitting; - } - @Selector([AccountSettingsState]) static getRegions(state: AccountSettingsStateModel): IdName[] { return state.regions; diff --git a/src/app/features/settings/account-settings/store/account-settings.state.ts b/src/app/features/settings/account-settings/store/account-settings.state.ts index bd4962746..fb57cac16 100644 --- a/src/app/features/settings/account-settings/store/account-settings.state.ts +++ b/src/app/features/settings/account-settings/store/account-settings.state.ts @@ -1,36 +1,29 @@ -import { Action, State, StateContext } from '@ngxs/store'; +import { Action, State, StateContext, Store } from '@ngxs/store'; import { catchError, tap, throwError } from 'rxjs'; import { inject, Injectable } from '@angular/core'; -import { SetCurrentUser } from '@core/store/user'; -import { handleSectionError } from '@osf/shared/helpers'; +import { SetCurrentUser, UserSelectors } from '@core/store/user'; import { InstitutionsService } from '@shared/services'; import { AccountSettingsService } from '../services'; import { - AddEmail, CancelDeactivationRequest, DeactivateAccount, - DeleteEmail, DeleteExternalIdentity, DeleteUserInstitution, DisableTwoFactorAuth, EnableTwoFactorAuth, GetAccountSettings, - GetEmails, GetExternalIdentities, GetRegions, GetUserInstitutions, - MakePrimary, - ResendConfirmation, UpdateAccountSettings, UpdateIndexing, UpdatePassword, UpdateRegion, - VerifyEmail, VerifyTwoFactorAuth, } from './account-settings.actions'; import { ACCOUNT_SETTINGS_STATE_DEFAULTS, AccountSettingsStateModel } from './account-settings.model'; @@ -43,103 +36,7 @@ import { ACCOUNT_SETTINGS_STATE_DEFAULTS, AccountSettingsStateModel } from './ac export class AccountSettingsState { private readonly accountSettingsService = inject(AccountSettingsService); private readonly institutionsService = inject(InstitutionsService); - - @Action(GetEmails) - getEmails(ctx: StateContext) { - const state = ctx.getState(); - - ctx.patchState({ emails: { ...state.emails, isLoading: true } }); - - return this.accountSettingsService.getEmails().pipe( - tap((emails) => { - ctx.patchState({ - emails: { - data: emails, - isLoading: false, - error: null, - }, - }); - }), - catchError((error) => handleSectionError(ctx, 'emails', error)) - ); - } - - @Action(AddEmail) - addEmail(ctx: StateContext, action: AddEmail) { - const state = ctx.getState(); - ctx.patchState({ emails: { ...state.emails, isSubmitting: true } }); - - return this.accountSettingsService.addEmail(action.email).pipe( - tap((email) => { - ctx.patchState({ - emails: { - data: state.emails.data, - isSubmitting: false, - isLoading: false, - error: null, - }, - }); - - if (email.emailAddress && !email.confirmed) { - ctx.dispatch(GetEmails); - } - }), - catchError((error) => handleSectionError(ctx, 'emails', error)) - ); - } - - @Action(DeleteEmail) - deleteEmail(ctx: StateContext, action: DeleteEmail) { - const state = ctx.getState(); - ctx.patchState({ emails: { ...state.emails, isLoading: true } }); - - return this.accountSettingsService.deleteEmail(action.email).pipe( - tap(() => { - ctx.patchState({ - emails: { - data: state.emails.data, - isSubmitting: false, - isLoading: false, - error: null, - }, - }); - - ctx.dispatch(GetEmails); - }), - catchError((error) => handleSectionError(ctx, 'emails', error)) - ); - } - - @Action(ResendConfirmation) - resendConfirmation(ctx: StateContext, action: ResendConfirmation) { - return this.accountSettingsService - .resendConfirmation(action.emailId, action.userId) - .pipe(catchError((error) => throwError(() => error))); - } - - @Action(VerifyEmail) - verifyEmail(ctx: StateContext, action: VerifyEmail) { - return this.accountSettingsService.verifyEmail(action.userId, action.emailId).pipe( - tap((email) => { - if (email.verified) { - ctx.dispatch(GetEmails); - } - }), - catchError((error) => throwError(() => error)) - ); - } - - @Action(MakePrimary) - makePrimary(ctx: StateContext, action: MakePrimary) { - return this.accountSettingsService.makePrimary(action.emailId).pipe( - tap((email) => { - if (email.verified) { - ctx.dispatch(GetEmails); - } - }), - catchError((error) => throwError(() => error)) - ); - } + private readonly store = inject(Store); @Action(GetRegions) getRegions(ctx: StateContext) { @@ -151,7 +48,13 @@ export class AccountSettingsState { @Action(UpdateRegion) updateRegion(ctx: StateContext, action: UpdateRegion) { - return this.accountSettingsService.updateLocation(action.regionId).pipe( + const currentUser = this.store.selectSnapshot(UserSelectors.getCurrentUser); + + if (!currentUser?.id) { + return; + } + + return this.accountSettingsService.updateLocation(currentUser.id, action.regionId).pipe( tap((user) => ctx.dispatch(new SetCurrentUser(user))), catchError((error) => throwError(() => error)) ); @@ -159,7 +62,13 @@ export class AccountSettingsState { @Action(UpdateIndexing) updateIndexing(ctx: StateContext, action: UpdateIndexing) { - return this.accountSettingsService.updateIndexing(action.allowIndexing).pipe( + const currentUser = this.store.selectSnapshot(UserSelectors.getCurrentUser); + + if (!currentUser?.id) { + return; + } + + return this.accountSettingsService.updateIndexing(currentUser.id, action.allowIndexing).pipe( tap((user) => ctx.dispatch(new SetCurrentUser(user))), catchError((error) => throwError(() => error)) ); @@ -207,7 +116,13 @@ export class AccountSettingsState { @Action(UpdateAccountSettings) updateAccountSettings(ctx: StateContext, action: UpdateAccountSettings) { - return this.accountSettingsService.updateSettings(action.accountSettings).pipe( + const currentUser = this.store.selectSnapshot(UserSelectors.getCurrentUser); + + if (!currentUser?.id) { + return; + } + + return this.accountSettingsService.updateSettings(currentUser.id, action.accountSettings).pipe( tap((settings) => ctx.patchState({ accountSettings: settings })), catchError((error) => throwError(() => error)) ); @@ -215,7 +130,13 @@ export class AccountSettingsState { @Action(DisableTwoFactorAuth) disableTwoFactorAuth(ctx: StateContext) { - return this.accountSettingsService.updateSettings({ two_factor_enabled: 'false' }).pipe( + const currentUser = this.store.selectSnapshot(UserSelectors.getCurrentUser); + + if (!currentUser?.id) { + return; + } + + return this.accountSettingsService.updateSettings(currentUser.id, { two_factor_enabled: 'false' }).pipe( tap((settings) => ctx.patchState({ accountSettings: settings })), catchError((error) => throwError(() => error)) ); @@ -223,7 +144,13 @@ export class AccountSettingsState { @Action(EnableTwoFactorAuth) enableTwoFactorAuth(ctx: StateContext) { - return this.accountSettingsService.updateSettings({ two_factor_enabled: 'true' }).pipe( + const currentUser = this.store.selectSnapshot(UserSelectors.getCurrentUser); + + if (!currentUser?.id) { + return; + } + + return this.accountSettingsService.updateSettings(currentUser.id, { two_factor_enabled: 'true' }).pipe( tap((settings) => ctx.patchState({ accountSettings: settings })), catchError((error) => throwError(() => error)) ); @@ -231,7 +158,13 @@ export class AccountSettingsState { @Action(VerifyTwoFactorAuth) verifyTwoFactorAuth(ctx: StateContext, action: VerifyTwoFactorAuth) { - return this.accountSettingsService.updateSettings({ two_factor_verification: action.code }).pipe( + const currentUser = this.store.selectSnapshot(UserSelectors.getCurrentUser); + + if (!currentUser?.id) { + return; + } + + return this.accountSettingsService.updateSettings(currentUser.id, { two_factor_verification: action.code }).pipe( tap((settings) => ctx.patchState({ accountSettings: settings })), catchError((error) => throwError(() => error)) ); @@ -239,7 +172,13 @@ export class AccountSettingsState { @Action(DeactivateAccount) deactivateAccount(ctx: StateContext) { - return this.accountSettingsService.updateSettings({ deactivation_requested: 'true' }).pipe( + const currentUser = this.store.selectSnapshot(UserSelectors.getCurrentUser); + + if (!currentUser?.id) { + return; + } + + return this.accountSettingsService.updateSettings(currentUser.id, { deactivation_requested: 'true' }).pipe( tap((settings) => ctx.patchState({ accountSettings: settings })), catchError((error) => throwError(() => error)) ); @@ -247,7 +186,13 @@ export class AccountSettingsState { @Action(CancelDeactivationRequest) cancelDeactivationRequest(ctx: StateContext) { - return this.accountSettingsService.updateSettings({ deactivation_requested: 'false' }).pipe( + const currentUser = this.store.selectSnapshot(UserSelectors.getCurrentUser); + + if (!currentUser?.id) { + return; + } + + return this.accountSettingsService.updateSettings(currentUser.id, { deactivation_requested: 'false' }).pipe( tap((settings) => ctx.patchState({ accountSettings: settings })), catchError((error) => throwError(() => error)) ); diff --git a/src/app/shared/components/add-project-form/add-project-form.component.ts b/src/app/shared/components/add-project-form/add-project-form.component.ts index 775fd76a7..aa21eb84c 100644 --- a/src/app/shared/components/add-project-form/add-project-form.component.ts +++ b/src/app/shared/components/add-project-form/add-project-form.component.ts @@ -15,10 +15,11 @@ import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ProjectFormControls } from '@osf/shared/enums'; import { Institution, ProjectForm } from '@osf/shared/models'; import { Project } from '@osf/shared/models/projects'; -import { AffiliatedInstitutionSelectComponent } from '@shared/components/affiliated-institution-select/affiliated-institution-select.component'; -import { ProjectSelectorComponent } from '@shared/components/project-selector/project-selector.component'; -import { FetchUserInstitutions, InstitutionsSelectors } from '@shared/stores/institutions'; -import { FetchRegions, RegionsSelectors } from '@shared/stores/regions'; +import { FetchRegions, RegionsSelectors } from '@osf/shared/stores'; +import { FetchUserInstitutions, InstitutionsSelectors } from '@osf/shared/stores/institutions'; + +import { AffiliatedInstitutionSelectComponent } from '../affiliated-institution-select/affiliated-institution-select.component'; +import { ProjectSelectorComponent } from '../project-selector/project-selector.component'; @Component({ selector: 'osf-add-project-form', diff --git a/src/app/features/home/components/confirm-email/confirm-email.component.html b/src/app/shared/components/confirm-email/confirm-email.component.html similarity index 83% rename from src/app/features/home/components/confirm-email/confirm-email.component.html rename to src/app/shared/components/confirm-email/confirm-email.component.html index 8c6acabfd..7a52115e1 100644 --- a/src/app/features/home/components/confirm-email/confirm-email.component.html +++ b/src/app/shared/components/confirm-email/confirm-email.component.html @@ -1,10 +1,10 @@
- @if (!verifyingEmail()) { -

+ @if (!isSubmitting()) { +

{{ 'home.confirmEmail.description' | translate }} -

{{ config.data.emailAddress }}

+ {{ email.emailAddress }} {{ 'home.confirmEmail.description2' | translate }} -

+

{ + let component: ConfirmEmailComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ConfirmEmailComponent], + providers: [TranslateServiceMock], + }).compileComponents(); + + fixture = TestBed.createComponent(ConfirmEmailComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/confirm-email/confirm-email.component.ts b/src/app/shared/components/confirm-email/confirm-email.component.ts new file mode 100644 index 000000000..6e1020e42 --- /dev/null +++ b/src/app/shared/components/confirm-email/confirm-email.component.ts @@ -0,0 +1,60 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; + +import { DeleteEmail, UserEmailsSelectors, VerifyEmail } from '@core/store/user-emails'; +import { LoadingSpinnerComponent } from '@osf/shared/components'; +import { AccountEmailModel } from '@osf/shared/models'; +import { ToastService } from '@osf/shared/services'; + +@Component({ + selector: 'osf-confirm-email', + imports: [Button, FormsModule, TranslatePipe, LoadingSpinnerComponent], + templateUrl: './confirm-email.component.html', + styleUrl: './confirm-email.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfirmEmailComponent { + private readonly dialogRef = inject(DynamicDialogRef); + private readonly config = inject(DynamicDialogConfig); + private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + + private readonly actions = createDispatchMap({ verifyEmail: VerifyEmail, deleteEmail: DeleteEmail }); + + isSubmitting = select(UserEmailsSelectors.isEmailsSubmitting); + + get email() { + return this.config.data[0] as AccountEmailModel; + } + + closeDialog() { + this.actions + .deleteEmail(this.email.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.toastService.showSuccess('home.confirmEmail.emailNotAdded', { name: this.email.emailAddress }); + this.dialogRef.close(); + }); + } + + verifyEmail() { + this.actions + .verifyEmail(this.email.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toastService.showSuccess('home.confirmEmail.emailVerified', { name: this.email.emailAddress }); + this.dialogRef.close(); + }, + error: () => this.dialogRef.close(), + }); + } +} diff --git a/src/app/shared/components/index.ts b/src/app/shared/components/index.ts index 8b6b3a881..347868e5a 100644 --- a/src/app/shared/components/index.ts +++ b/src/app/shared/components/index.ts @@ -2,6 +2,7 @@ export { AddProjectFormComponent } from './add-project-form/add-project-form.com export { AffiliatedInstitutionSelectComponent } from './affiliated-institution-select/affiliated-institution-select.component'; export { AffiliatedInstitutionsViewComponent } from './affiliated-institutions-view/affiliated-institutions-view.component'; export { BarChartComponent } from './bar-chart/bar-chart.component'; +export { ConfirmEmailComponent } from './confirm-email/confirm-email.component'; export { CopyButtonComponent } from './copy-button/copy-button.component'; export { CustomPaginatorComponent } from './custom-paginator/custom-paginator.component'; export { DataResourcesComponent } from './data-resources/data-resources.component'; diff --git a/src/app/shared/mappers/emails.mapper.ts b/src/app/shared/mappers/emails.mapper.ts new file mode 100644 index 000000000..78d2c39e5 --- /dev/null +++ b/src/app/shared/mappers/emails.mapper.ts @@ -0,0 +1,16 @@ +import { AccountEmailModel, EmailsDataJsonApi } from '../models/emails'; + +export function MapEmails(emails: EmailsDataJsonApi[]): AccountEmailModel[] { + return emails.map((item) => MapEmail(item)); +} + +export function MapEmail(email: EmailsDataJsonApi): AccountEmailModel { + return { + id: email.id, + emailAddress: email.attributes.email_address, + confirmed: email.attributes.confirmed, + verified: email.attributes.verified, + primary: email.attributes.primary, + isMerge: email.attributes.is_merge, + }; +} diff --git a/src/app/shared/mappers/index.ts b/src/app/shared/mappers/index.ts index 8f9023fad..5025cb248 100644 --- a/src/app/shared/mappers/index.ts +++ b/src/app/shared/mappers/index.ts @@ -4,6 +4,7 @@ export * from './collections'; export * from './components'; export * from './contributors'; export * from './duplicates.mapper'; +export * from './emails.mapper'; export * from './files/files.mapper'; export * from './filters'; export * from './institutions'; diff --git a/src/app/shared/models/emails/account-email.model.ts b/src/app/shared/models/emails/account-email.model.ts new file mode 100644 index 000000000..1aa26526e --- /dev/null +++ b/src/app/shared/models/emails/account-email.model.ts @@ -0,0 +1,8 @@ +export interface AccountEmailModel { + id: string; + emailAddress: string; + confirmed: boolean; + verified: boolean; + primary: boolean; + isMerge: boolean; +} diff --git a/src/app/shared/models/emails/account-emails-json-api.model.ts b/src/app/shared/models/emails/account-emails-json-api.model.ts new file mode 100644 index 000000000..6ac4ba918 --- /dev/null +++ b/src/app/shared/models/emails/account-emails-json-api.model.ts @@ -0,0 +1,18 @@ +import { ResponseDataJsonApi, ResponseJsonApi } from '@osf/shared/models'; + +export type EmailsResponseJsonApi = ResponseJsonApi; + +export type EmailResponseJsonApi = ResponseDataJsonApi; + +export interface EmailsDataJsonApi { + id: string; + attributes: EmailsAttributesJsonApi; +} + +interface EmailsAttributesJsonApi { + email_address: string; + confirmed: boolean; + verified: boolean; + primary: boolean; + is_merge: boolean; +} diff --git a/src/app/shared/models/emails/index.ts b/src/app/shared/models/emails/index.ts new file mode 100644 index 000000000..7e1b93b2b --- /dev/null +++ b/src/app/shared/models/emails/index.ts @@ -0,0 +1,2 @@ +export * from './account-email.model'; +export * from './account-emails-json-api.model'; diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts index dfa9a7433..139299706 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -12,6 +12,7 @@ export * from './confirmation-options.model'; export * from './contributors'; export * from './create-component-form.model'; export * from './current-resource.model'; +export * from './emails'; export * from './files'; export * from './filter-labels.model'; export * from './filters'; diff --git a/src/app/shared/services/toast.service.ts b/src/app/shared/services/toast.service.ts index 56a9bb255..850fdef1a 100644 --- a/src/app/shared/services/toast.service.ts +++ b/src/app/shared/services/toast.service.ts @@ -12,11 +12,11 @@ export class ToastService { this.messageService.add({ severity: 'success', summary, data: { translationParams: params } }); } - showWarn(summary: string) { - this.messageService.add({ severity: 'warn', summary }); + showWarn(summary: string, params?: unknown) { + this.messageService.add({ severity: 'warn', summary, data: { translationParams: params } }); } - showError(summary: string) { - this.messageService.add({ severity: 'error', summary, life: 5000 }); + showError(summary: string, params?: unknown) { + this.messageService.add({ severity: 'error', summary, life: 5000, data: { translationParams: params } }); } } diff --git a/src/app/shared/stores/institutions/institutions.model.ts b/src/app/shared/stores/institutions/institutions.model.ts index 27fe568bf..5eb7bf2aa 100644 --- a/src/app/shared/stores/institutions/institutions.model.ts +++ b/src/app/shared/stores/institutions/institutions.model.ts @@ -6,7 +6,7 @@ export interface InstitutionsStateModel { resourceInstitutions: AsyncStateModel; } -export const DefaultState = { +export const INSTITUTIONS_STATE_DEFAULTS: InstitutionsStateModel = { userInstitutions: { data: [], isLoading: false, diff --git a/src/app/shared/stores/institutions/institutions.state.ts b/src/app/shared/stores/institutions/institutions.state.ts index 69874ded3..0d892891b 100644 --- a/src/app/shared/stores/institutions/institutions.state.ts +++ b/src/app/shared/stores/institutions/institutions.state.ts @@ -14,11 +14,11 @@ import { FetchUserInstitutions, UpdateResourceInstitutions, } from './institutions.actions'; -import { DefaultState, InstitutionsStateModel } from './institutions.model'; +import { INSTITUTIONS_STATE_DEFAULTS, InstitutionsStateModel } from './institutions.model'; @State({ name: 'institutions', - defaults: { ...DefaultState }, + defaults: INSTITUTIONS_STATE_DEFAULTS, }) @Injectable() export class InstitutionsState { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 5bdf80f05..47d2e714f 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -328,7 +328,9 @@ "title": "Add alternative email", "description": "Do you want to add ", "description2": "to your profile ?", - "goToEmails": "Add email" + "goToEmails": "Add email", + "emailNotAdded": "{{name}} has not been added to your account.", + "emailVerified": "{{name}} has been added to your account." } }, "myProjects": { @@ -522,7 +524,7 @@ }, "curatorInfo": { "heading": "Curator Information", - "description": "An administrator designated by your affiliated institution to curate your project" + "description": "An administrator designated by your affiliated institution to curate your project." }, "employment": { "show": "Show employment history",