diff --git a/package.json b/package.json
index 75592ea3c..f27b09edb 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,7 @@
"@types/jasmine": "~5.1.0",
"@types/jest": "^29.5.14",
"angular-eslint": "19.1.0",
+ "angularx-qrcode": "^19.0.0",
"eslint": "^9.20.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-import": "^2.31.0",
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 6b7fd219c..c7b266930 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -38,7 +38,9 @@ export const routes: Routes = [
{
path: 'home-logged-out',
loadComponent: () =>
- import('./features/home/logged-out/home-logged-out.component').then((mod) => mod.HomeLoggedOutComponent),
+ import('@osf/features/home/components/logged-out/home-logged-out.component').then(
+ (mod) => mod.HomeLoggedOutComponent
+ ),
},
{
path: 'support',
@@ -117,6 +119,10 @@ export const routes: Routes = [
loadComponent: () => import('./features/my-profile/my-profile.component').then((mod) => mod.MyProfileComponent),
providers: [provideStates([ResourceFiltersState, ResourceFiltersOptionsState])],
},
+ {
+ path: 'confirm/:userId/:emailId',
+ loadComponent: () => import('./features/home/home.component').then((mod) => mod.HomeComponent),
+ },
],
},
];
diff --git a/src/app/core/components/breadcrumb/breadcrumb.component.html b/src/app/core/components/breadcrumb/breadcrumb.component.html
index 291606465..f34f2ce67 100644
--- a/src/app/core/components/breadcrumb/breadcrumb.component.html
+++ b/src/app/core/components/breadcrumb/breadcrumb.component.html
@@ -1,4 +1,4 @@
-@if (parsedUrl()[0] !== 'home') {
+@if (!parsedUrl()[0].includes('home') && !parsedUrl()[0].includes('confirm')) {
/
diff --git a/src/app/core/constants/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts
index 279b085b9..93ad98ce6 100644
--- a/src/app/core/constants/ngxs-states.constant.ts
+++ b/src/app/core/constants/ngxs-states.constant.ts
@@ -3,6 +3,7 @@ import { UserState } from '@core/store/user';
import { InstitutionsState } from '@osf/features/institutions/store';
import { MyProjectsState } from '@osf/features/my-projects/store';
import { SearchState } from '@osf/features/search/store';
+import { AccountSettingsState } from '@osf/features/settings/account-settings/store/account-settings.state';
import { AddonsState } from '@osf/features/settings/addons/store';
import { DeveloperAppsState } from '@osf/features/settings/developer-apps/store';
import { ProfileSettingsState } from '@osf/features/settings/profile-settings/profile-settings.state';
@@ -18,4 +19,5 @@ export const STATES = [
InstitutionsState,
ProfileSettingsState,
DeveloperAppsState,
+ AccountSettingsState,
];
diff --git a/src/app/core/services/json-api/json-api.service.ts b/src/app/core/services/json-api/json-api.service.ts
index ea16cdf27..3a70ba532 100644
--- a/src/app/core/services/json-api/json-api.service.ts
+++ b/src/app/core/services/json-api/json-api.service.ts
@@ -11,6 +11,8 @@ import { JsonApiResponse } from '@core/services/json-api/json-api.entity';
export class JsonApiService {
http: HttpClient = inject(HttpClient);
readonly #token = 'Bearer 2rjFZwmdDG4rtKj7hGkEMO6XyHBM2lN7XBbsA1e8OqcFhOWu6Z7fQZiheu9RXtzSeVrgOt';
+ // OBJoUomBgbUuDgQo5JoaSKNya6XaYcd0ojAX1XOLmWi6J2arQPzByxyEi81fHE60drQUWv
+ // UlO9O9GNKgVzJD7pUeY53jiQTKJ4U2znXVWNvh0KZQruoENuILx0IIYf9LoDz7Duq72EIm
readonly #headers = new HttpHeaders({
Authorization: this.#token,
Accept: 'application/vnd.api+json',
@@ -65,7 +67,7 @@ export class JsonApiService {
.pipe(map((response) => response.data));
}
- delete(url: string): Observable
{
- return this.http.delete(url, { headers: this.#headers });
+ delete(url: string, body?: unknown): Observable {
+ return this.http.delete(url, { headers: this.#headers, body: body });
}
}
diff --git a/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts b/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts
index efb16cd2b..4e1942c0f 100644
--- a/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts
+++ b/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts
@@ -16,8 +16,15 @@ export interface UserUS {
suffix?: string;
social: Social;
date_registered: string;
+ allow_indexing?: boolean;
+ };
+ relationships: {
+ default_region: {
+ data: {
+ id: string;
+ };
+ };
};
- relationships: Record;
links: {
html: string;
profile_image: string;
diff --git a/src/app/core/services/mappers/users/users.mapper.ts b/src/app/core/services/mappers/users/users.mapper.ts
index 0cde5c429..bea7973ae 100644
--- a/src/app/core/services/mappers/users/users.mapper.ts
+++ b/src/app/core/services/mappers/users/users.mapper.ts
@@ -16,5 +16,7 @@ export function mapUserUStoUser(user: UserUS): User {
employment: user.attributes.employment,
iri: user.links.iri,
social: user.attributes.social,
+ defaultRegionId: user.relationships?.default_region?.data?.id,
+ allowIndexing: user.attributes?.allow_indexing,
};
}
diff --git a/src/app/core/services/user/user.entity.ts b/src/app/core/services/user/user.entity.ts
index 47ffc35a4..e90d83eb5 100644
--- a/src/app/core/services/user/user.entity.ts
+++ b/src/app/core/services/user/user.entity.ts
@@ -25,4 +25,6 @@ export interface User {
impactStory?: string;
researcherId?: string;
};
+ defaultRegionId: string;
+ allowIndexing: boolean | undefined;
}
diff --git a/src/app/features/home/components/confirm-email/confirm-email.component.html b/src/app/features/home/components/confirm-email/confirm-email.component.html
new file mode 100644
index 000000000..948f95be5
--- /dev/null
+++ b/src/app/features/home/components/confirm-email/confirm-email.component.html
@@ -0,0 +1,16 @@
+
+
+ {{ 'home.confirmEmail.description' | translate }}
+ {{ config.data.emailAddress }}
+ {{ 'home.confirmEmail.description2' | translate }}
+
+
+
+ {{ 'home.confirmEmail.buttons.doNotAdd' | translate }}
+
+
+
+ {{ 'home.confirmEmail.buttons.addEmail' | translate }}
+
+
+
diff --git a/src/app/features/home/components/confirm-email/confirm-email.component.scss b/src/app/features/home/components/confirm-email/confirm-email.component.scss
new file mode 100644
index 000000000..bbf1fde7b
--- /dev/null
+++ b/src/app/features/home/components/confirm-email/confirm-email.component.scss
@@ -0,0 +1,5 @@
+:host {
+ .description {
+ text-transform: none;
+ }
+}
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
new file mode 100644
index 000000000..d0c783e86
--- /dev/null
+++ b/src/app/features/home/components/confirm-email/confirm-email.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ConfirmEmailComponent } from './confirm-email.component';
+
+describe('ConfirmEmailComponent', () => {
+ let component: ConfirmEmailComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ConfirmEmailComponent],
+ }).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
new file mode 100644
index 000000000..556113652
--- /dev/null
+++ b/src/app/features/home/components/confirm-email/confirm-email.component.ts
@@ -0,0 +1,31 @@
+import { Store } from '@ngxs/store';
+
+import { TranslateModule } from '@ngx-translate/core';
+
+import { Button } from 'primeng/button';
+import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
+
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+
+import { VerifyEmail } from '@osf/features/settings/account-settings/store/account-settings.actions';
+
+@Component({
+ selector: 'osf-confirm-email',
+ imports: [Button, FormsModule, TranslateModule],
+ templateUrl: './confirm-email.component.html',
+ styleUrl: './confirm-email.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ConfirmEmailComponent {
+ readonly #store = inject(Store);
+ readonly dialogRef = inject(DynamicDialogRef);
+ readonly config = inject(DynamicDialogConfig);
+ readonly #router = inject(Router);
+
+ verifyEmail() {
+ this.#store.dispatch(new VerifyEmail(this.config.data.userId, this.config.data.emailId));
+ this.#router.navigate(['/settings/account']);
+ }
+}
diff --git a/src/app/features/home/logged-out/data.ts b/src/app/features/home/components/logged-out/data.ts
similarity index 89%
rename from src/app/features/home/logged-out/data.ts
rename to src/app/features/home/components/logged-out/data.ts
index ba9e93d3c..b05cf828f 100644
--- a/src/app/features/home/logged-out/data.ts
+++ b/src/app/features/home/components/logged-out/data.ts
@@ -4,21 +4,21 @@ export const slides = [
name: 'Patricia Ayala',
title: 'home.loggedOut.testimonials.slides.patricia.quote',
author: 'Patricia Ayala',
- facility: 'home.logged-out.testimonials.slides.patricia.facility',
+ facility: 'home.loggedOut.testimonials.slides.patricia.facility',
},
{
img: 'assets/images/carousel2.png',
name: 'Maya Mathur',
title: 'home.loggedOut.testimonials.slides.maya.quote',
author: 'Maya Mathur',
- facility: 'home.logged-out.testimonials.slides.maya.facility',
+ facility: 'home.loggedOut.testimonials.slides.maya.facility',
},
{
img: 'assets/images/carousel3.png',
name: 'Philip Cohen',
title: 'home.loggedOut.testimonials.slides.philip.quote',
author: 'Philip Cohen',
- facility: 'home.logged-out.testimonials.slides.philip.facility',
+ facility: 'home.loggedOut.testimonials.slides.philip.facility',
},
];
diff --git a/src/app/features/home/logged-out/home-logged-out.component.html b/src/app/features/home/components/logged-out/home-logged-out.component.html
similarity index 98%
rename from src/app/features/home/logged-out/home-logged-out.component.html
rename to src/app/features/home/components/logged-out/home-logged-out.component.html
index bcff11b65..2c9df5ac5 100644
--- a/src/app/features/home/logged-out/home-logged-out.component.html
+++ b/src/app/features/home/components/logged-out/home-logged-out.component.html
@@ -85,7 +85,7 @@
-
+
diff --git a/src/app/features/home/logged-out/home-logged-out.component.scss b/src/app/features/home/components/logged-out/home-logged-out.component.scss
similarity index 100%
rename from src/app/features/home/logged-out/home-logged-out.component.scss
rename to src/app/features/home/components/logged-out/home-logged-out.component.scss
diff --git a/src/app/features/home/logged-out/home-logged-out.component.spec.ts b/src/app/features/home/components/logged-out/home-logged-out.component.spec.ts
similarity index 100%
rename from src/app/features/home/logged-out/home-logged-out.component.spec.ts
rename to src/app/features/home/components/logged-out/home-logged-out.component.spec.ts
diff --git a/src/app/features/home/logged-out/home-logged-out.component.ts b/src/app/features/home/components/logged-out/home-logged-out.component.ts
similarity index 100%
rename from src/app/features/home/logged-out/home-logged-out.component.ts
rename to src/app/features/home/components/logged-out/home-logged-out.component.ts
diff --git a/src/app/features/home/home.component.ts b/src/app/features/home/home.component.ts
index 3e903035d..72d866a3c 100644
--- a/src/app/features/home/home.component.ts
+++ b/src/app/features/home/home.component.ts
@@ -4,20 +4,22 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { SortEvent } from 'primeng/api';
import { Button } from 'primeng/button';
-import { DialogService } from 'primeng/dynamicdialog';
+import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog';
import { TablePageEvent } from 'primeng/table';
-import { debounceTime, distinctUntilChanged, Subject } from 'rxjs';
+import { debounceTime, distinctUntilChanged, Subject, take } from 'rxjs';
import { Component, computed, DestroyRef, effect, inject, OnInit, signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MY_PROJECTS_TABLE_PARAMS } from '@core/constants/my-projects-table.constants';
+import { ConfirmEmailComponent } from '@osf/features/home/components/confirm-email/confirm-email.component';
import { GetUserInstitutions } from '@osf/features/institutions/store';
import { MyProjectsItem } from '@osf/features/my-projects/entities/my-projects.entities';
import { MyProjectsSearchFilters } from '@osf/features/my-projects/entities/my-projects-search-filters.models';
import { ClearMyProjects, GetMyProjects, MyProjectsSelectors } from '@osf/features/my-projects/store';
+import { AccountSettingsService } from '@osf/features/settings/account-settings/services/account-settings.service';
import { AddProjectFormComponent } from '@shared/components/add-project-form/add-project-form.component';
import { MyProjectsTableComponent } from '@shared/components/my-projects-table/my-projects-table.component';
import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.component';
@@ -43,6 +45,7 @@ export class HomeComponent implements OnInit {
readonly #isXSmall$ = inject(IS_XSMALL);
readonly #isMedium$ = inject(IS_MEDIUM);
readonly #searchSubject = new Subject();
+ readonly #accountSettingsServer = inject(AccountSettingsService);
protected readonly isLoading = signal(false);
protected readonly isSubmitting = signal(false);
@@ -66,6 +69,9 @@ export class HomeComponent implements OnInit {
return this.projects().filter((project) => project.title.toLowerCase().includes(search));
});
+ dialogRef: DynamicDialogRef | null = null;
+ emailAddress = '';
+
constructor() {
this.#setupSearchSubscription();
this.#setupTotalRecordsEffect();
@@ -75,6 +81,38 @@ export class HomeComponent implements OnInit {
ngOnInit() {
this.#setupQueryParamsSubscription();
this.#store.dispatch(new GetUserInstitutions());
+
+ // Check for userId and emailId route parameters
+ this.#route.params.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((params) => {
+ const userId = params['userId'];
+ const emailId = params['emailId'];
+
+ if (userId && emailId) {
+ this.#accountSettingsServer
+ .getEmail(emailId, userId)
+ .pipe(take(1))
+ .subscribe((email) => {
+ this.emailAddress = email.emailAddress;
+ this.addAlternateEmail();
+ });
+ }
+ });
+ }
+
+ addAlternateEmail() {
+ this.dialogRef = this.#dialogService.open(ConfirmEmailComponent, {
+ width: '448px',
+ focusOnShow: false,
+ header: this.#translateService.instant('home.confirmEmail.title'),
+ closeOnEscape: true,
+ modal: true,
+ closable: true,
+ data: {
+ emailAddress: this.emailAddress,
+ userId: this.#route.snapshot.params['userId'],
+ emailId: this.#route.snapshot.params['emailId'],
+ },
+ });
}
#setupQueryParamsSubscription(): void {
diff --git a/src/app/features/institutions/institutions.service.ts b/src/app/features/institutions/institutions.service.ts
index 72f93fe97..58f498981 100644
--- a/src/app/features/institutions/institutions.service.ts
+++ b/src/app/features/institutions/institutions.service.ts
@@ -6,6 +6,8 @@ import { inject, Injectable } from '@angular/core';
import { JsonApiResponse } from '@core/services/json-api/json-api.entity';
import { JsonApiService } from '@core/services/json-api/json-api.service';
+import { environment } from '../../../environments/environment';
+
import { Institution, UserInstitutionGetResponse } from './entities/institutions.models';
import { InstitutionsMapper } from './mappers/institutions.mapper';
@@ -23,4 +25,11 @@ export class InstitutionsService {
.get>(url)
.pipe(map((response) => response.data.map((item) => InstitutionsMapper.fromResponse(item))));
}
+
+ deleteUserInstitution(id: string, userId: string): Observable {
+ const payload = {
+ data: [{ id: id, type: 'institutions' }],
+ };
+ return this.#jsonApiService.delete(`${environment.apiUrl}/users/${userId}/relationships/institutions/`, payload);
+ }
}
diff --git a/src/app/features/settings/account-settings/account-settings.component.html b/src/app/features/settings/account-settings/account-settings.component.html
index 3876b9ede..5f49e314f 100644
--- a/src/app/features/settings/account-settings/account-settings.component.html
+++ b/src/app/features/settings/account-settings/account-settings.component.html
@@ -1,196 +1,21 @@
-
-
-
+@if (this.currentUser()?.id) {
+
+
-
- To merge an existing account with this one or to log in with multiple email addresses, add an alternate email
- address below. All projects and components will be displayed under the email address listed as primary.
-
+
-
-
-
Primary Email:
+
-
email@example.com
-
+
-
-
Alternate Emails:
+
-
-
email@example.com
+
-
Make Primary
+
-
email@example.com
-
-
Make Primary
-
-
-
-
-
Unconfirmed emails:
-
-
-
email@example.com
-
-
Resend Confirmation
-
-
-
-
-
-
-
-
-
-
-
- This location will be applied to new projects and components. It will not affect existing projects and components.
-
-
-
-
-
-
-
-
-
-
-
- Connected identities allow you to log in to the OSF via a third-party service. You can revoke these identifies.
-
-
-
-
ORCID: 0000 - 0003 -0199 - 7112 (pending)
-
-
-
-
-
-
-
- Affiliated Institutions connect your OSF account to your institution or organization. Affiliated institutions can
- be removed from your profile.
-
-
-
-
Center for Open Science
-
-
-
-
-
-
-
- By default, OSF users are indexed into SHARE, a free, open dataset of research metadata. This allows SHARE to
- include your user profile and research in its database, which is used by search engines and other services to make
- research more discoverable. You can opt out of this indexing by checking the box below. NOTE: Public projects,
- files, registrations, and preprints will still be indexed in SHARE.
-
Learn more about SHARE
-
-
-
-
-
-
-
Out of SHARE Indexing
-
-
-
-
-
-
Opt In To SHARE Indexing
-
-
-
-
-
-
-
-
-
-
-
-
- Two-factor authentication protects your OSF account using both your password and email.
-
-
-
-
-
-
-
-
- Warning: This action cannot be undone once approved.
-
-
- Deactivating your account will remove you from all public projects to which you are a contributor. Your account
- will no longer be associated with OSF projects, and your work on the OSF will be inaccessible. If this is a
- secondary account that you want to close, consider merging your accounts.
-
-
-
+
-
+}
diff --git a/src/app/features/settings/account-settings/account-settings.component.scss b/src/app/features/settings/account-settings/account-settings.component.scss
index db8c5e9db..1164df561 100644
--- a/src/app/features/settings/account-settings/account-settings.component.scss
+++ b/src/app/features/settings/account-settings/account-settings.component.scss
@@ -42,6 +42,8 @@
flex-direction: column;
gap: 1.7rem;
border-radius: 0.5rem;
+ font-weight: 400;
+ text-transform: none;
&-link {
font-weight: 600;
@@ -65,6 +67,8 @@
&-description {
line-height: 2rem;
+ text-transform: none;
+ font-weight: 400;
}
&-action {
@@ -85,7 +89,11 @@
&-email {
display: flex;
gap: 2rem;
- align-items: center;
+ align-items: start;
+
+ &__title {
+ min-width: 10rem;
+ }
&--readonly {
display: flex;
@@ -93,6 +101,12 @@
border: 1px solid var.$grey-2;
padding: 0.285rem 0.85rem;
border-radius: 0.285rem;
+
+ i {
+ color: var.$dark-blue-1;
+ font-size: 0.7rem;
+ margin-left: 0.7rem;
+ }
}
&__value {
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 fa895f8c6..245f8c22a 100644
--- a/src/app/features/settings/account-settings/account-settings.component.ts
+++ b/src/app/features/settings/account-settings/account-settings.component.ts
@@ -1,70 +1,65 @@
-import { Button } from 'primeng/button';
-import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog';
-import { InputText } from 'primeng/inputtext';
-import { Message } from 'primeng/message';
-import { RadioButton } from 'primeng/radiobutton';
-import { Select } from 'primeng/select';
+import { Store } from '@ngxs/store';
-import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { DialogService } from 'primeng/dynamicdialog';
+
+import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
-import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { ReactiveFormsModule } from '@angular/forms';
+import { UserSelectors } from '@core/store/user/user.selectors';
+import {
+ AffiliatedInstitutionsComponent,
+ ChangePasswordComponent,
+ ConnectedEmailsComponent,
+ ConnectedIdentitiesComponent,
+ DeactivateAccountComponent,
+ DefaultStorageLocationComponent,
+ ShareIndexingComponent,
+ TwoFactorAuthComponent,
+} from '@osf/features/settings/account-settings/components';
import {
- AccountSettingsPasswordForm,
- AccountSettingsPasswordFormControls,
-} from '@osf/features/settings/account-settings/account.settings.entities';
-import { MOCK_COUNTRIES } from '@osf/features/settings/account-settings/account-settings.const';
-import { AddEmailComponent } from '@osf/features/settings/account-settings/add-email/add-email.component';
-import { DeactivateAccountComponent } from '@osf/features/settings/account-settings/deactivate-account/deactivate-account/deactivate-account.component';
+ GetAccountSettings,
+ GetEmails,
+ GetExternalIdentities,
+ GetRegions,
+ GetUserInstitutions,
+} from '@osf/features/settings/account-settings/store/account-settings.actions';
import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.component';
import { IS_XSMALL } from '@shared/utils/breakpoints.tokens';
@Component({
selector: 'osf-account-settings',
- imports: [Button, Select, RadioButton, ReactiveFormsModule, InputText, Message, SubHeaderComponent],
+ imports: [
+ ReactiveFormsModule,
+ SubHeaderComponent,
+ ConnectedEmailsComponent,
+ DefaultStorageLocationComponent,
+ ConnectedIdentitiesComponent,
+ ShareIndexingComponent,
+ ChangePasswordComponent,
+ TwoFactorAuthComponent,
+ DeactivateAccountComponent,
+ AffiliatedInstitutionsComponent,
+ ],
providers: [DialogService],
templateUrl: './account-settings.component.html',
styleUrl: './account-settings.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AccountSettingsComponent {
+ readonly #store = inject(Store);
protected readonly isMobile = toSignal(inject(IS_XSMALL));
- readonly passwordForm: AccountSettingsPasswordForm = new FormGroup({
- [AccountSettingsPasswordFormControls.OldPassword]: new FormControl('', {
- nonNullable: true,
- }),
- [AccountSettingsPasswordFormControls.NewPassword]: new FormControl('', {
- nonNullable: true,
- }),
- [AccountSettingsPasswordFormControls.ConfirmPassword]: new FormControl('', {
- nonNullable: true,
- }),
- });
- protected readonly optControl = new FormControl(false);
- protected readonly MOCK_COUNTRIES = MOCK_COUNTRIES;
- protected readonly AccountSettingsPasswordFormControls = AccountSettingsPasswordFormControls;
- private readonly dialogService = inject(DialogService);
- private dialogRef: DynamicDialogRef | null = null;
-
- addEmail() {
- this.dialogRef = this.dialogService.open(AddEmailComponent, {
- width: '448px',
- focusOnShow: false,
- header: 'Add alternative email',
- closeOnEscape: true,
- modal: true,
- closable: true,
- });
- }
+ protected readonly currentUser = this.#store.selectSignal(UserSelectors.getCurrentUser);
- deactivateAccount() {
- this.dialogRef = this.dialogService.open(DeactivateAccountComponent, {
- width: '448px',
- focusOnShow: false,
- header: 'Deactivate account',
- closeOnEscape: true,
- modal: true,
- closable: true,
+ constructor() {
+ effect(() => {
+ if (this.currentUser()) {
+ this.#store.dispatch(GetAccountSettings);
+ this.#store.dispatch(GetEmails);
+ this.#store.dispatch(GetExternalIdentities);
+ this.#store.dispatch(GetRegions);
+ this.#store.dispatch(GetUserInstitutions);
+ }
});
}
}
diff --git a/src/app/features/settings/account-settings/account-settings.const.ts b/src/app/features/settings/account-settings/account-settings.const.ts
deleted file mode 100644
index 658c0efd8..000000000
--- a/src/app/features/settings/account-settings/account-settings.const.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-export interface Country {
- name: string;
- code: string;
-}
-
-export const MOCK_COUNTRIES: Country[] = [
- {
- name: 'United States',
- code: 'US',
- },
- {
- name: 'Canada',
- code: 'CA',
- },
-];
diff --git a/src/app/features/settings/account-settings/account-settings.route.ts b/src/app/features/settings/account-settings/account-settings.route.ts
deleted file mode 100644
index c9de1f6f8..000000000
--- a/src/app/features/settings/account-settings/account-settings.route.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { Routes } from '@angular/router';
-
-export const accountSettingsRoute: Routes[] = [];
diff --git a/src/app/features/settings/account-settings/add-email/add-email.component.html b/src/app/features/settings/account-settings/add-email/add-email.component.html
deleted file mode 100644
index 688a68d0e..000000000
--- a/src/app/features/settings/account-settings/add-email/add-email.component.html
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
- Email
-
-
-
-
-
- Cancel
-
-
-
Add Email
-
-
diff --git a/src/app/features/settings/account-settings/components/add-email/add-email.component.html b/src/app/features/settings/account-settings/components/add-email/add-email.component.html
new file mode 100644
index 000000000..66059257f
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/add-email/add-email.component.html
@@ -0,0 +1,34 @@
+
+
+ {{ 'settings.accountSettings.addEmail.form.email' | translate }}
+
+
+
+
+
diff --git a/src/app/features/settings/account-settings/add-email/add-email.component.scss b/src/app/features/settings/account-settings/components/add-email/add-email.component.scss
similarity index 100%
rename from src/app/features/settings/account-settings/add-email/add-email.component.scss
rename to src/app/features/settings/account-settings/components/add-email/add-email.component.scss
diff --git a/src/app/features/settings/account-settings/add-email/add-email.component.spec.ts b/src/app/features/settings/account-settings/components/add-email/add-email.component.spec.ts
similarity index 100%
rename from src/app/features/settings/account-settings/add-email/add-email.component.spec.ts
rename to src/app/features/settings/account-settings/components/add-email/add-email.component.spec.ts
diff --git a/src/app/features/settings/account-settings/add-email/add-email.component.ts b/src/app/features/settings/account-settings/components/add-email/add-email.component.ts
similarity index 59%
rename from src/app/features/settings/account-settings/add-email/add-email.component.ts
rename to src/app/features/settings/account-settings/components/add-email/add-email.component.ts
index 4483daaf9..7ed228484 100644
--- a/src/app/features/settings/account-settings/add-email/add-email.component.ts
+++ b/src/app/features/settings/account-settings/components/add-email/add-email.component.ts
@@ -1,3 +1,7 @@
+import { Store } from '@ngxs/store';
+
+import { TranslatePipe } from '@ngx-translate/core';
+
import { Button } from 'primeng/button';
import { DynamicDialogRef } from 'primeng/dynamicdialog';
import { InputText } from 'primeng/inputtext';
@@ -5,15 +9,25 @@ import { InputText } from 'primeng/inputtext';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
+import { AddEmail } from '@osf/features/settings/account-settings/store/account-settings.actions';
+
@Component({
selector: 'osf-add-email',
- imports: [InputText, ReactiveFormsModule, Button],
+ imports: [InputText, ReactiveFormsModule, Button, TranslatePipe],
templateUrl: './add-email.component.html',
styleUrl: './add-email.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddEmailComponent {
+ readonly #store = inject(Store);
readonly dialogRef = inject(DynamicDialogRef);
protected readonly emailControl = new FormControl('', [Validators.email, Validators.required]);
+
+ addEmail() {
+ if (this.emailControl.value) {
+ this.#store.dispatch(new AddEmail(this.emailControl.value));
+ }
+ this.dialogRef.close();
+ }
}
diff --git a/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.html b/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.html
new file mode 100644
index 000000000..69d6d3224
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.html
@@ -0,0 +1,27 @@
+
+
+
+
+ {{ 'settings.accountSettings.affiliatedInstitutions.description' | translate }}
+
+
+ @if (institutions().length === 0) {
+
+ {{ 'settings.accountSettings.affiliatedInstitutions.noInstitutions' | translate }}
+
+ }
+ @for (institution of institutions(); track institution.id) {
+
+
+
{{ institution.name }}
+
+
+
+
+ }
+
diff --git a/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.scss b/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.scss
new file mode 100644
index 000000000..21819b894
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.scss
@@ -0,0 +1,57 @@
+@use "../../account-settings.component.scss" as account-settings;
+@use "assets/styles/variables" as var;
+
+:host {
+ @extend .account-setting;
+
+ h3,
+ p {
+ font-weight: 400;
+ text-transform: none;
+ }
+
+ .account-setting {
+ &-emails {
+ display: flex;
+ flex-direction: column;
+ gap: 1.7rem;
+ }
+
+ &-email {
+ display: flex;
+ gap: 2rem;
+ align-items: start;
+
+ &--readonly {
+ display: flex;
+ align-items: center;
+ border: 1px solid var.$grey-2;
+ padding: 0.285rem 0.85rem;
+ border-radius: 0.285rem;
+ min-height: 2.8rem;
+
+ &--address {
+ max-width: 14rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ i {
+ color: var.$dark-blue-1;
+ font-size: 0.7rem;
+ margin-left: 0.7rem;
+ cursor: pointer;
+ font-weight: 400;
+ }
+ }
+
+ &__value {
+ display: flex;
+ align-items: center;
+ gap: 0.428rem;
+ min-height: 2.8rem;
+ }
+ }
+ }
+}
diff --git a/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.spec.ts b/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.spec.ts
new file mode 100644
index 000000000..eb12c0470
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { AffiliatedInstitutionsComponent } from './affiliated-institutions.component';
+
+describe('AffiliatedInstitutionsComponent', () => {
+ let component: AffiliatedInstitutionsComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [AffiliatedInstitutionsComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(AffiliatedInstitutionsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.ts b/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.ts
new file mode 100644
index 000000000..211e0160b
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.ts
@@ -0,0 +1,30 @@
+import { Store } from '@ngxs/store';
+
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { Button } from 'primeng/button';
+
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+
+import { UserSelectors } from '@core/store/user/user.selectors';
+import { DeleteUserInstitution } from '@osf/features/settings/account-settings/store/account-settings.actions';
+import { AccountSettingsSelectors } from '@osf/features/settings/account-settings/store/account-settings.selectors';
+
+@Component({
+ selector: 'osf-affiliated-institutions',
+ imports: [Button, TranslatePipe],
+ templateUrl: './affiliated-institutions.component.html',
+ styleUrl: './affiliated-institutions.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AffiliatedInstitutionsComponent {
+ readonly #store = inject(Store);
+ institutions = this.#store.selectSignal(AccountSettingsSelectors.getUserInstitutions);
+ currentUser = this.#store.selectSignal(UserSelectors.getCurrentUser);
+
+ deleteInstitution(id: string) {
+ if (this.currentUser()?.id) {
+ this.#store.dispatch(new DeleteUserInstitution(id, this.currentUser()!.id));
+ }
+ }
+}
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
new file mode 100644
index 000000000..d0b39cce4
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/change-password/change-password.component.html
@@ -0,0 +1,108 @@
+
diff --git a/src/app/features/settings/account-settings/components/change-password/change-password.component.scss b/src/app/features/settings/account-settings/components/change-password/change-password.component.scss
new file mode 100644
index 000000000..780b45db8
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/change-password/change-password.component.scss
@@ -0,0 +1,16 @@
+@use "../../account-settings.component.scss" as account-settings;
+@use "assets/styles/variables" as var;
+
+:host {
+ @extend .account-setting;
+
+ .password-label {
+ color: var.$dark-blue-1;
+ }
+
+ .password-help {
+ color: var.$pr-blue-1;
+ font-size: 0.8rem;
+ font-weight: 600;
+ }
+}
diff --git a/src/app/features/settings/account-settings/components/change-password/change-password.component.spec.ts b/src/app/features/settings/account-settings/components/change-password/change-password.component.spec.ts
new file mode 100644
index 000000000..2ecf1439e
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/change-password/change-password.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ChangePasswordComponent } from './change-password.component';
+
+describe('ChangePasswordComponent', () => {
+ let component: ChangePasswordComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ChangePasswordComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ChangePasswordComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
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
new file mode 100644
index 000000000..391b4e47a
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/change-password/change-password.component.ts
@@ -0,0 +1,123 @@
+import { TranslatePipe, TranslateService } from '@ngx-translate/core';
+
+import { Button } from 'primeng/button';
+import { Password } from 'primeng/password';
+
+import { CommonModule } from '@angular/common';
+import { HttpErrorResponse } from '@angular/common/http';
+import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
+import {
+ AbstractControl,
+ FormControl,
+ FormGroup,
+ ReactiveFormsModule,
+ ValidationErrors,
+ Validators,
+} from '@angular/forms';
+
+import {
+ AccountSettingsPasswordForm,
+ AccountSettingsPasswordFormControls,
+} from '@osf/features/settings/account-settings/account.settings.entities';
+
+import { AccountSettingsService } from '../../services';
+
+@Component({
+ selector: 'osf-change-password',
+ imports: [ReactiveFormsModule, Password, CommonModule, Button, TranslatePipe],
+ templateUrl: './change-password.component.html',
+ styleUrl: './change-password.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ChangePasswordComponent implements OnInit {
+ readonly #accountSettingsService = inject(AccountSettingsService);
+ readonly #translateService = inject(TranslateService);
+ readonly passwordForm: AccountSettingsPasswordForm = new FormGroup({
+ [AccountSettingsPasswordFormControls.OldPassword]: new FormControl('', {
+ nonNullable: true,
+ validators: [Validators.required],
+ }),
+ [AccountSettingsPasswordFormControls.NewPassword]: new FormControl('', {
+ nonNullable: true,
+ validators: [
+ Validators.required,
+ Validators.minLength(8),
+ Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[\d!@#$%^&*])[A-Za-z\d!@#$%^&*_]{8,}$/),
+ ],
+ }),
+ [AccountSettingsPasswordFormControls.ConfirmPassword]: new FormControl('', {
+ nonNullable: true,
+ validators: [Validators.required],
+ }),
+ });
+
+ protected readonly AccountSettingsPasswordFormControls = AccountSettingsPasswordFormControls;
+ protected errorMessage = signal('');
+
+ ngOnInit(): void {
+ // Add form-level validator for password matching and old password check
+ this.passwordForm.addValidators((control: AbstractControl): ValidationErrors | null => {
+ const oldPassword = control.get(AccountSettingsPasswordFormControls.OldPassword)?.value;
+ const newPassword = control.get(AccountSettingsPasswordFormControls.NewPassword)?.value;
+ const confirmPassword = control.get(AccountSettingsPasswordFormControls.ConfirmPassword)?.value;
+
+ const errors: ValidationErrors = {};
+
+ // Check if new password matches old password
+ if (oldPassword && newPassword && oldPassword === newPassword) {
+ errors['sameAsOldPassword'] = true;
+ }
+
+ // Check if confirm password matches new password
+ if (newPassword && confirmPassword && newPassword !== confirmPassword) {
+ errors['passwordMismatch'] = true;
+ }
+
+ return Object.keys(errors).length > 0 ? errors : null;
+ });
+
+ // Update validation when any password field changes
+ this.passwordForm.get(AccountSettingsPasswordFormControls.OldPassword)?.valueChanges.subscribe(() => {
+ this.passwordForm.updateValueAndValidity();
+ });
+
+ this.passwordForm.get(AccountSettingsPasswordFormControls.NewPassword)?.valueChanges.subscribe(() => {
+ this.passwordForm.updateValueAndValidity();
+ });
+
+ this.passwordForm.get(AccountSettingsPasswordFormControls.ConfirmPassword)?.valueChanges.subscribe(() => {
+ this.passwordForm.updateValueAndValidity();
+ });
+ }
+
+ changePassword() {
+ Object.values(this.passwordForm.controls).forEach((control) => {
+ control.markAsTouched();
+ });
+
+ if (this.passwordForm.valid) {
+ this.errorMessage.set('');
+ const oldPassword = this.passwordForm.get(AccountSettingsPasswordFormControls.OldPassword)?.value ?? '';
+ const newPassword = this.passwordForm.get(AccountSettingsPasswordFormControls.NewPassword)?.value ?? '';
+
+ this.#accountSettingsService.updatePassword(oldPassword, newPassword).subscribe({
+ next: () => {
+ this.passwordForm.reset();
+ Object.values(this.passwordForm.controls).forEach((control) => {
+ control.markAsUntouched();
+ });
+ },
+ error: (error: HttpErrorResponse) => {
+ console.error(error);
+ if (error.error?.errors?.[0]?.detail) {
+ this.errorMessage.set(error.error.errors[0].detail);
+ } else {
+ this.errorMessage.set(
+ this.#translateService.instant('settings.accountSettings.changePassword.messages.error')
+ );
+ }
+ },
+ });
+ }
+ }
+}
diff --git a/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.html b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.html
new file mode 100644
index 000000000..11d189634
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.html
@@ -0,0 +1,113 @@
+
+
+
+
+ {{ 'settings.accountSettings.connectedEmails.description' | translate }}
+
+
+
+
+
+ {{ 'settings.accountSettings.connectedEmails.primaryEmail' | translate }}
+
+
+ @if (isEmailsLoading()) {
+
+ } @else {
+
{{ primaryEmail()?.emailAddress }}
+ }
+
+
+
+
+ {{ 'settings.accountSettings.connectedEmails.alternateEmails' | translate }}
+
+
+
+ @if (isEmailsLoading()) {
+
+ } @else {
+ @for (email of confirmedEmails(); track email.id) {
+
+
+
{{ email.emailAddress }}
+ @if (!deletingEmailIds().has(email.id)) {
+
+
+ }
+ @if (deletingEmailIds().has(email.id)) {
+
+ }
+
+
+
+ {{ 'settings.accountSettings.connectedEmails.buttons.makePrimary' | translate }}
+
+
+ }
+ }
+
+
+
+
+
+ {{ 'settings.accountSettings.connectedEmails.unconfirmedEmails' | translate }}
+
+
+
+ @if (isEmailsLoading()) {
+
+ } @else {
+ @for (email of unconfirmedEmails(); track email.id) {
+
+
+
{{ email.emailAddress }}
+
+ @if (!deletingEmailIds().has(email.id)) {
+
+ }
+ @if (deletingEmailIds().has(email.id)) {
+
+ }
+
+
+
+ @if (isMobile()) {
+ {{ 'settings.accountSettings.connectedEmails.buttons.resend' | translate }}
+ } @else {
+ {{ 'settings.accountSettings.connectedEmails.buttons.resendConfirmation' | translate }}
+ }
+
+
+ }
+ }
+
+
+
+
+
+
+ {{ 'settings.accountSettings.connectedEmails.buttons.addEmail' | translate }}
+
+
+
diff --git a/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.scss b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.scss
new file mode 100644
index 000000000..7ac7beb37
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.scss
@@ -0,0 +1,98 @@
+@use "../../account-settings.component.scss" as account-settings;
+@use "assets/styles/variables" as var;
+
+:host {
+ @extend .account-setting;
+
+ .account-setting {
+ &-emails {
+ display: flex;
+ flex-direction: column;
+ gap: 1.7rem;
+ }
+
+ &-email {
+ display: flex;
+ gap: 2rem;
+ align-items: start;
+
+ &-center {
+ display: flex;
+ gap: 2rem;
+ align-items: center;
+ }
+
+ &__title {
+ min-width: 10rem;
+ min-height: 2.8rem;
+ display: flex;
+ align-items: center;
+ }
+
+ &__container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2rem;
+ }
+
+ &--readonly {
+ display: flex;
+ align-items: center;
+ border: 1px solid var.$grey-2;
+ padding: 0.285rem 0.85rem;
+ border-radius: 0.285rem;
+ min-height: 2.8rem;
+
+ &--address {
+ max-width: 14rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ i {
+ color: var.$dark-blue-1;
+ font-size: 0.7rem;
+ margin-left: 0.7rem;
+ cursor: pointer;
+ font-weight: 400;
+ }
+ }
+
+ &__value {
+ display: flex;
+ align-items: center;
+ gap: 0.428rem;
+ min-height: 2.8rem;
+ }
+ }
+ }
+
+ @media (max-width: 600px) {
+ .account-setting-email {
+ flex-wrap: wrap;
+ gap: 0.5rem;
+
+ &__title {
+ min-height: 2rem;
+ }
+
+ &__container {
+ gap: 0.7rem;
+ }
+
+ &__value {
+ min-height: 2rem;
+ }
+ }
+ }
+
+ @media (max-width: 410px) {
+ .account-setting-email {
+ &__value {
+ min-height: 2rem;
+ flex-wrap: wrap;
+ }
+ }
+ }
+}
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
new file mode 100644
index 000000000..0132f7023
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ConnectedEmailsComponent } from './connected-emails.component';
+
+describe('ConnectedEmailsComponent', () => {
+ let component: ConnectedEmailsComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ConnectedEmailsComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ConnectedEmailsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
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
new file mode 100644
index 000000000..5b1be936b
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.ts
@@ -0,0 +1,86 @@
+import { Store } from '@ngxs/store';
+
+import { TranslatePipe, TranslateService } from '@ngx-translate/core';
+
+import { Button } from 'primeng/button';
+import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog';
+import { ProgressSpinner } from 'primeng/progressspinner';
+import { Skeleton } from 'primeng/skeleton';
+
+import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
+
+import { UserSelectors } from '@core/store/user/user.selectors';
+import { AddEmailComponent } from '@osf/features/settings/account-settings/components';
+import { AccountSettingsService } from '@osf/features/settings/account-settings/services/account-settings.service';
+import { DeleteEmail } from '@osf/features/settings/account-settings/store/account-settings.actions';
+import { AccountSettingsSelectors } from '@osf/features/settings/account-settings/store/account-settings.selectors';
+import { IS_XSMALL } from '@shared/utils/breakpoints.tokens';
+
+@Component({
+ selector: 'osf-connected-emails',
+ imports: [Button, ProgressSpinner, Skeleton, TranslatePipe],
+ templateUrl: './connected-emails.component.html',
+ styleUrl: './connected-emails.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ConnectedEmailsComponent {
+ readonly #store = inject(Store);
+ readonly #accountSettingsService = inject(AccountSettingsService);
+ readonly #dialogService = inject(DialogService);
+ readonly #translateService = inject(TranslateService);
+ readonly isMobile = toSignal(inject(IS_XSMALL));
+
+ protected readonly currentUser = this.#store.selectSignal(UserSelectors.getCurrentUser);
+ protected readonly emails = this.#store.selectSignal(AccountSettingsSelectors.getEmails);
+ protected readonly deletingEmailIds = signal>(new Set());
+ protected readonly unconfirmedEmails = computed(() => {
+ return this.emails().filter((email) => !email.confirmed && !email.primary);
+ });
+ protected readonly confirmedEmails = computed(() => {
+ return this.emails().filter((email) => email.confirmed && !email.primary);
+ });
+ protected readonly primaryEmail = computed(() => {
+ return this.emails().find((email) => email.primary);
+ });
+ protected readonly isEmailsLoading = this.#store.selectSignal(AccountSettingsSelectors.isEmailsLoading);
+
+ dialogRef: DynamicDialogRef | null = null;
+
+ addEmail() {
+ this.dialogRef = this.#dialogService.open(AddEmailComponent, {
+ width: '448px',
+ focusOnShow: false,
+ header: this.#translateService.instant('settings.accountSettings.connectedEmails.dialog.title'),
+ closeOnEscape: true,
+ modal: true,
+ closable: true,
+ });
+ }
+
+ resendConfirmation(emailId: string) {
+ if (this.currentUser()?.id) {
+ this.#accountSettingsService.resendConfirmation(emailId, this.currentUser()!.id).subscribe();
+ }
+ }
+
+ makePrimary(emailId: string) {
+ if (this.currentUser()?.id) {
+ this.#accountSettingsService.makePrimary(emailId).subscribe();
+ }
+ }
+
+ deleteEmail(emailId: string) {
+ const currentDeletingIds = this.deletingEmailIds();
+ currentDeletingIds.add(emailId);
+ this.deletingEmailIds.set(currentDeletingIds);
+
+ this.#store.dispatch(new DeleteEmail(emailId)).subscribe({
+ complete: () => {
+ const updatedDeletingIds = this.deletingEmailIds();
+ updatedDeletingIds.delete(emailId);
+ this.deletingEmailIds.set(updatedDeletingIds);
+ },
+ });
+ }
+}
diff --git a/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.html b/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.html
new file mode 100644
index 000000000..732cb0488
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.html
@@ -0,0 +1,25 @@
+
+
+
+
+ {{ 'settings.accountSettings.connectedIdentities.description' | translate }}
+
+
+
+ @for (identity of externalIdentities(); track identity.id) {
+
+ {{ identity.id }}: {{ identity.externalId }} ({{ identity.status }})
+
+
+
+ }
+ @if (!externalIdentities().length) {
+
{{ 'settings.accountSettings.connectedIdentities.noIdentities' | translate }}
+ }
+
+
diff --git a/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.scss b/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.scss
new file mode 100644
index 000000000..3f1e57889
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.scss
@@ -0,0 +1,5 @@
+@use "../../account-settings.component.scss" as account-settings;
+
+:host {
+ @extend .account-setting;
+}
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
new file mode 100644
index 000000000..c0bd40c20
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ConnectedIdentitiesComponent } from './connected-identities.component';
+
+describe('ConnectedIdentitiesComponent', () => {
+ let component: ConnectedIdentitiesComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ConnectedIdentitiesComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ConnectedIdentitiesComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
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
new file mode 100644
index 000000000..b9cec9b7f
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.ts
@@ -0,0 +1,24 @@
+import { Store } from '@ngxs/store';
+
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+
+import { DeleteExternalIdentity } from '@osf/features/settings/account-settings/store/account-settings.actions';
+import { AccountSettingsSelectors } from '@osf/features/settings/account-settings/store/account-settings.selectors';
+
+@Component({
+ selector: 'osf-connected-identities',
+ imports: [TranslatePipe],
+ templateUrl: './connected-identities.component.html',
+ styleUrl: './connected-identities.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ConnectedIdentitiesComponent {
+ readonly #store = inject(Store);
+ readonly externalIdentities = this.#store.selectSignal(AccountSettingsSelectors.getExternalIdentities);
+
+ deleteExternalIdentity(id: string): void {
+ this.#store.dispatch(new DeleteExternalIdentity(id));
+ }
+}
diff --git a/src/app/features/settings/account-settings/components/deactivate-account/components/cancel-deactivation/cancel-deactivation.component.html b/src/app/features/settings/account-settings/components/deactivate-account/components/cancel-deactivation/cancel-deactivation.component.html
new file mode 100644
index 000000000..239af46f3
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/deactivate-account/components/cancel-deactivation/cancel-deactivation.component.html
@@ -0,0 +1,29 @@
+
+
+
+ {{ 'settings.accountSettings.deactivateAccount.cancelDeactivation.confirm' | translate }}
+
+
+ {{ 'settings.accountSettings.deactivateAccount.cancelDeactivation.description' | translate }}
+
+
+
+
+
diff --git a/src/app/features/settings/account-settings/components/deactivate-account/components/cancel-deactivation/cancel-deactivation.component.scss b/src/app/features/settings/account-settings/components/deactivate-account/components/cancel-deactivation/cancel-deactivation.component.scss
new file mode 100644
index 000000000..b2606ff4d
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/deactivate-account/components/cancel-deactivation/cancel-deactivation.component.scss
@@ -0,0 +1,5 @@
+@use "../../../../account-settings.component.scss" as account-settings;
+
+:host {
+ @extend .account-setting;
+}
diff --git a/src/app/features/settings/account-settings/components/deactivate-account/components/cancel-deactivation/cancel-deactivation.component.spec.ts b/src/app/features/settings/account-settings/components/deactivate-account/components/cancel-deactivation/cancel-deactivation.component.spec.ts
new file mode 100644
index 000000000..5c1d134eb
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/deactivate-account/components/cancel-deactivation/cancel-deactivation.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { CancelDeactivationComponent } from './cancel-deactivation.component';
+
+describe('CancelDeactivationComponent', () => {
+ let component: CancelDeactivationComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [CancelDeactivationComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(CancelDeactivationComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/features/settings/account-settings/components/deactivate-account/components/cancel-deactivation/cancel-deactivation.component.ts b/src/app/features/settings/account-settings/components/deactivate-account/components/cancel-deactivation/cancel-deactivation.component.ts
new file mode 100644
index 000000000..94b1182a7
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/deactivate-account/components/cancel-deactivation/cancel-deactivation.component.ts
@@ -0,0 +1,27 @@
+import { Store } from '@ngxs/store';
+
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { Button } from 'primeng/button';
+import { DynamicDialogRef } from 'primeng/dynamicdialog';
+
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+
+import { CancelDeactivationRequest } from '@osf/features/settings/account-settings/store/account-settings.actions';
+
+@Component({
+ selector: 'osf-cancel-deactivation',
+ imports: [Button, TranslatePipe],
+ templateUrl: './cancel-deactivation.component.html',
+ styleUrl: './cancel-deactivation.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CancelDeactivationComponent {
+ #store = inject(Store);
+ dialogRef = inject(DynamicDialogRef);
+
+ cancelDeactivation(): void {
+ this.#store.dispatch(CancelDeactivationRequest);
+ this.dialogRef.close();
+ }
+}
diff --git a/src/app/features/settings/account-settings/components/deactivate-account/components/deactivation-warning/deactivation-warning.component.html b/src/app/features/settings/account-settings/components/deactivate-account/components/deactivation-warning/deactivation-warning.component.html
new file mode 100644
index 000000000..017d79bc6
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/deactivate-account/components/deactivation-warning/deactivation-warning.component.html
@@ -0,0 +1,29 @@
+
+
+
+ {{ 'settings.accountSettings.deactivateAccount.warning.confirm' | translate }}
+
+
+ {{ 'settings.accountSettings.deactivateAccount.warning.description' | translate }}
+
+
+
+
+
diff --git a/src/app/features/settings/account-settings/components/deactivate-account/components/deactivation-warning/deactivation-warning.component.scss b/src/app/features/settings/account-settings/components/deactivate-account/components/deactivation-warning/deactivation-warning.component.scss
new file mode 100644
index 000000000..b2606ff4d
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/deactivate-account/components/deactivation-warning/deactivation-warning.component.scss
@@ -0,0 +1,5 @@
+@use "../../../../account-settings.component.scss" as account-settings;
+
+:host {
+ @extend .account-setting;
+}
diff --git a/src/app/features/settings/account-settings/components/deactivate-account/components/deactivation-warning/deactivation-warning.component.spec.ts b/src/app/features/settings/account-settings/components/deactivate-account/components/deactivation-warning/deactivation-warning.component.spec.ts
new file mode 100644
index 000000000..15eb45a4e
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/deactivate-account/components/deactivation-warning/deactivation-warning.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { DeactivationWarningComponent } from './deactivation-warning.component';
+
+describe('DeactivationWarningComponent', () => {
+ let component: DeactivationWarningComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [DeactivationWarningComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(DeactivationWarningComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/features/settings/account-settings/components/deactivate-account/components/deactivation-warning/deactivation-warning.component.ts b/src/app/features/settings/account-settings/components/deactivate-account/components/deactivation-warning/deactivation-warning.component.ts
new file mode 100644
index 000000000..8bb8ed345
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/deactivate-account/components/deactivation-warning/deactivation-warning.component.ts
@@ -0,0 +1,27 @@
+import { Store } from '@ngxs/store';
+
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { Button } from 'primeng/button';
+import { DynamicDialogRef } from 'primeng/dynamicdialog';
+
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+
+import { DeactivateAccount } from '@osf/features/settings/account-settings/store/account-settings.actions';
+
+@Component({
+ selector: 'osf-deactivation-warning',
+ imports: [Button, TranslatePipe],
+ templateUrl: './deactivation-warning.component.html',
+ styleUrl: './deactivation-warning.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class DeactivationWarningComponent {
+ #store = inject(Store);
+ dialogRef = inject(DynamicDialogRef);
+
+ deactivateAccount(): void {
+ this.#store.dispatch(DeactivateAccount);
+ this.dialogRef.close();
+ }
+}
diff --git a/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.html b/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.html
new file mode 100644
index 000000000..d5c9f23d6
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+ {{ 'settings.accountSettings.deactivateAccount.warning.title' | translate }}
+
+
+
+
+ {{ 'settings.accountSettings.deactivateAccount.description.main' | translate }}
+
+
+
+ {{ 'settings.accountSettings.deactivateAccount.description.secondary' | translate }}
+
+
+ @if (accountSettings()?.deactivationRequested) {
+ {{ 'settings.accountSettings.deactivateAccount.pendingDeactivation' | translate }}
+
+
+ {{ 'settings.accountSettings.deactivateAccount.actions.undoDeactivation' | translate }}
+
+
+ } @else {
+
+
+ {{ 'settings.accountSettings.deactivateAccount.actions.requestDeactivation' | translate }}
+
+
+ }
+
diff --git a/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.scss b/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.scss
new file mode 100644
index 000000000..3f1e57889
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.scss
@@ -0,0 +1,5 @@
+@use "../../account-settings.component.scss" as account-settings;
+
+:host {
+ @extend .account-setting;
+}
diff --git a/src/app/features/settings/account-settings/deactivate-account/deactivate-account/deactivate-account.component.spec.ts b/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.spec.ts
similarity index 100%
rename from src/app/features/settings/account-settings/deactivate-account/deactivate-account/deactivate-account.component.spec.ts
rename to src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.spec.ts
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
new file mode 100644
index 000000000..9774d9a53
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.ts
@@ -0,0 +1,51 @@
+import { Store } from '@ngxs/store';
+
+import { TranslatePipe, TranslateService } from '@ngx-translate/core';
+
+import { Button } from 'primeng/button';
+import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog';
+import { Message } from 'primeng/message';
+
+import { NgOptimizedImage } from '@angular/common';
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+
+import { CancelDeactivationComponent } from '@osf/features/settings/account-settings/components/deactivate-account/components/cancel-deactivation/cancel-deactivation.component';
+import { DeactivationWarningComponent } from '@osf/features/settings/account-settings/components/deactivate-account/components/deactivation-warning/deactivation-warning.component';
+import { AccountSettingsSelectors } from '@osf/features/settings/account-settings/store/account-settings.selectors';
+
+@Component({
+ selector: 'osf-deactivate-account',
+ imports: [Button, Message, NgOptimizedImage, TranslatePipe],
+ templateUrl: './deactivate-account.component.html',
+ styleUrl: './deactivate-account.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class DeactivateAccountComponent {
+ #store = inject(Store);
+ dialogRef: DynamicDialogRef | null = null;
+ readonly #dialogService = inject(DialogService);
+ readonly #translateService = inject(TranslateService);
+ protected accountSettings = this.#store.selectSignal(AccountSettingsSelectors.getAccountSettings);
+
+ deactivateAccount() {
+ this.dialogRef = this.#dialogService.open(DeactivationWarningComponent, {
+ width: '552px',
+ focusOnShow: false,
+ header: this.#translateService.instant('settings.accountSettings.deactivateAccount.dialog.deactivate.title'),
+ closeOnEscape: true,
+ modal: true,
+ closable: true,
+ });
+ }
+
+ cancelDeactivation() {
+ this.dialogRef = this.#dialogService.open(CancelDeactivationComponent, {
+ width: '552px',
+ focusOnShow: false,
+ header: this.#translateService.instant('settings.accountSettings.deactivateAccount.dialog.undo.title'),
+ closeOnEscape: true,
+ modal: true,
+ closable: true,
+ });
+ }
+}
diff --git a/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.html b/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.html
new file mode 100644
index 000000000..06cb8bced
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.html
@@ -0,0 +1,24 @@
+
+
+
+
+ {{ 'settings.accountSettings.defaultStorageLocation.description' | translate }}
+
+
+
+
+
+
{{
+ 'settings.accountSettings.defaultStorageLocation.buttons.update' | translate
+ }}
+
+
diff --git a/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.scss b/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.scss
new file mode 100644
index 000000000..3f1e57889
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.scss
@@ -0,0 +1,5 @@
+@use "../../account-settings.component.scss" as account-settings;
+
+:host {
+ @extend .account-setting;
+}
diff --git a/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.spec.ts b/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.spec.ts
new file mode 100644
index 000000000..c4501c647
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { DefaultStorageLocationComponent } from './default-storage-location.component';
+
+describe('DefaultStorageLocationComponent', () => {
+ let component: DefaultStorageLocationComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [DefaultStorageLocationComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(DefaultStorageLocationComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
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
new file mode 100644
index 000000000..9879e1b92
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.ts
@@ -0,0 +1,49 @@
+import { Store } from '@ngxs/store';
+
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { Button } from 'primeng/button';
+import { Select } from 'primeng/select';
+
+import { ChangeDetectionStrategy, Component, effect, inject, signal } from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { FormsModule } from '@angular/forms';
+
+import { UserSelectors } from '@core/store/user/user.selectors';
+import { Region } from '@osf/features/settings/account-settings/models/osf-models/region.model';
+import { UpdateRegion } from '@osf/features/settings/account-settings/store/account-settings.actions';
+import { AccountSettingsSelectors } from '@osf/features/settings/account-settings/store/account-settings.selectors';
+import { IS_XSMALL } from '@shared/utils/breakpoints.tokens';
+
+@Component({
+ selector: 'osf-default-storage-location',
+ imports: [Button, Select, FormsModule, TranslatePipe],
+ templateUrl: './default-storage-location.component.html',
+ styleUrl: './default-storage-location.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class DefaultStorageLocationComponent {
+ protected readonly isMobile = toSignal(inject(IS_XSMALL));
+ readonly #store = inject(Store);
+
+ protected readonly currentUser = this.#store.selectSignal(UserSelectors.getCurrentUser);
+ protected readonly regions = this.#store.selectSignal(AccountSettingsSelectors.getRegions);
+ protected selectedRegion = signal(undefined);
+
+ constructor() {
+ effect(() => {
+ const user = this.currentUser();
+ const regions = this.regions();
+ if (user && regions) {
+ const defaultRegion = regions.find((region) => region.id === user.defaultRegionId);
+ this.selectedRegion.set(defaultRegion);
+ }
+ });
+ }
+
+ updateLocation(): void {
+ if (this.selectedRegion()?.id) {
+ this.#store.dispatch(new UpdateRegion(this.selectedRegion()!.id));
+ }
+ }
+}
diff --git a/src/app/features/settings/account-settings/components/index.ts b/src/app/features/settings/account-settings/components/index.ts
new file mode 100644
index 000000000..cf85ae786
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/index.ts
@@ -0,0 +1,9 @@
+export * from './add-email/add-email.component';
+export * from './affiliated-institutions/affiliated-institutions.component';
+export * from './change-password/change-password.component';
+export * from './connected-emails/connected-emails.component';
+export * from './connected-identities/connected-identities.component';
+export * from './deactivate-account/deactivate-account.component';
+export * from './default-storage-location/default-storage-location.component';
+export * from './share-indexing/share-indexing.component';
+export * from './two-factor-auth/two-factor-auth.component';
diff --git a/src/app/features/settings/account-settings/components/share-indexing/enums/share-indexing.enum.ts b/src/app/features/settings/account-settings/components/share-indexing/enums/share-indexing.enum.ts
new file mode 100644
index 000000000..ea9e237aa
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/share-indexing/enums/share-indexing.enum.ts
@@ -0,0 +1,5 @@
+export enum ShareIndexingEnum {
+ None = -1,
+ OutOf = 0,
+ OptIn = 1,
+}
diff --git a/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.html b/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.html
new file mode 100644
index 000000000..88c0cb0f8
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
{{ 'settings.accountSettings.shareIndexing.options.optOut' | translate }}
+
+
+
+
+
{{ 'settings.accountSettings.shareIndexing.options.optIn' | translate }}
+
+
+
+
+
{{
+ 'settings.accountSettings.shareIndexing.buttons.update' | translate
+ }}
+
+
diff --git a/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.scss b/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.scss
new file mode 100644
index 000000000..3f1e57889
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.scss
@@ -0,0 +1,5 @@
+@use "../../account-settings.component.scss" as account-settings;
+
+:host {
+ @extend .account-setting;
+}
diff --git a/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.spec.ts b/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.spec.ts
new file mode 100644
index 000000000..e5e313f4d
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ShareIndexingComponent } from './share-indexing.component';
+
+describe('ShareIndexingComponent', () => {
+ let component: ShareIndexingComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ShareIndexingComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ShareIndexingComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
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
new file mode 100644
index 000000000..ea4f7d50b
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.ts
@@ -0,0 +1,47 @@
+import { Store } from '@ngxs/store';
+
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { Button } from 'primeng/button';
+import { RadioButton } from 'primeng/radiobutton';
+
+import { ChangeDetectionStrategy, Component, effect, inject, signal } from '@angular/core';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+
+import { UserSelectors } from '@core/store/user/user.selectors';
+import { ShareIndexingEnum } from '@osf/features/settings/account-settings/components/share-indexing/enums/share-indexing.enum';
+import { UpdateIndexing } from '@osf/features/settings/account-settings/store/account-settings.actions';
+
+@Component({
+ selector: 'osf-share-indexing',
+ imports: [Button, RadioButton, ReactiveFormsModule, FormsModule, TranslatePipe],
+ templateUrl: './share-indexing.component.html',
+ styleUrl: './share-indexing.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ShareIndexingComponent {
+ readonly #store = inject(Store);
+ protected indexing = signal(ShareIndexingEnum.None);
+ protected readonly currentUser = this.#store.selectSignal(UserSelectors.getCurrentUser);
+
+ updateIndexing = () => {
+ if (this.currentUser()?.id) {
+ if (this.indexing() === ShareIndexingEnum.OptIn) {
+ this.#store.dispatch(new UpdateIndexing(true));
+ } else if (this.indexing() === ShareIndexingEnum.OutOf) {
+ this.#store.dispatch(new UpdateIndexing(false));
+ }
+ }
+ };
+
+ constructor() {
+ effect(() => {
+ const user = this.currentUser();
+ if (user?.allowIndexing) {
+ this.indexing.set(ShareIndexingEnum.OptIn);
+ } else if (user?.allowIndexing === false) {
+ this.indexing.set(ShareIndexingEnum.OutOf);
+ }
+ });
+ }
+}
diff --git a/src/app/features/settings/account-settings/components/two-factor-auth/components/configure-two-factor/configure-two-factor.component.html b/src/app/features/settings/account-settings/components/two-factor-auth/components/configure-two-factor/configure-two-factor.component.html
new file mode 100644
index 000000000..375cccbc9
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/two-factor-auth/components/configure-two-factor/configure-two-factor.component.html
@@ -0,0 +1,28 @@
+
+
+
+ {{ 'settings.accountSettings.twoFactorAuth.configure.description.main' | translate }}
+
+
+ {{ 'settings.accountSettings.twoFactorAuth.configure.description.steps' | translate }}
+
+
+
+
+
diff --git a/src/app/features/settings/account-settings/components/two-factor-auth/components/configure-two-factor/configure-two-factor.component.scss b/src/app/features/settings/account-settings/components/two-factor-auth/components/configure-two-factor/configure-two-factor.component.scss
new file mode 100644
index 000000000..b2606ff4d
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/two-factor-auth/components/configure-two-factor/configure-two-factor.component.scss
@@ -0,0 +1,5 @@
+@use "../../../../account-settings.component.scss" as account-settings;
+
+:host {
+ @extend .account-setting;
+}
diff --git a/src/app/features/settings/account-settings/components/two-factor-auth/components/configure-two-factor/configure-two-factor.component.spec.ts b/src/app/features/settings/account-settings/components/two-factor-auth/components/configure-two-factor/configure-two-factor.component.spec.ts
new file mode 100644
index 000000000..f6c406efb
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/two-factor-auth/components/configure-two-factor/configure-two-factor.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ConfigureTwoFactorComponent } from './configure-two-factor.component';
+
+describe('ConfigureTwoFactorComponent', () => {
+ let component: ConfigureTwoFactorComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ConfigureTwoFactorComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ConfigureTwoFactorComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/features/settings/account-settings/components/two-factor-auth/components/configure-two-factor/configure-two-factor.component.ts b/src/app/features/settings/account-settings/components/two-factor-auth/components/configure-two-factor/configure-two-factor.component.ts
new file mode 100644
index 000000000..791aa8614
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/two-factor-auth/components/configure-two-factor/configure-two-factor.component.ts
@@ -0,0 +1,32 @@
+import { Store } from '@ngxs/store';
+
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { Button } from 'primeng/button';
+import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
+
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+
+import { AccountSettings } from '@osf/features/settings/account-settings/models/osf-models/account-settings.model';
+import { EnableTwoFactorAuth } from '@osf/features/settings/account-settings/store/account-settings.actions';
+
+@Component({
+ selector: 'osf-configure-two-factor',
+ imports: [Button, FormsModule, TranslatePipe],
+ templateUrl: './configure-two-factor.component.html',
+ styleUrl: './configure-two-factor.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ConfigureTwoFactorComponent {
+ #store = inject(Store);
+ dialogRef = inject(DynamicDialogRef);
+ readonly config = inject(DynamicDialogConfig);
+
+ enableTwoFactor(): void {
+ const settings = this.config.data as AccountSettings;
+ settings.twoFactorEnabled = true;
+ this.#store.dispatch(EnableTwoFactorAuth);
+ this.dialogRef.close();
+ }
+}
diff --git a/src/app/features/settings/account-settings/components/two-factor-auth/components/index.ts b/src/app/features/settings/account-settings/components/two-factor-auth/components/index.ts
new file mode 100644
index 000000000..d0dcc5236
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/two-factor-auth/components/index.ts
@@ -0,0 +1,2 @@
+export * from './configure-two-factor/configure-two-factor.component';
+export * from './verify-two-factor/verify-two-factor.component';
diff --git a/src/app/features/settings/account-settings/components/two-factor-auth/components/verify-two-factor/verify-two-factor.component.html b/src/app/features/settings/account-settings/components/two-factor-auth/components/verify-two-factor/verify-two-factor.component.html
new file mode 100644
index 000000000..38fc920a7
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/two-factor-auth/components/verify-two-factor/verify-two-factor.component.html
@@ -0,0 +1,23 @@
+
+
+ {{ 'settings.accountSettings.twoFactorAuth.verify.title' | translate }}
+
+
+
diff --git a/src/app/features/settings/account-settings/components/two-factor-auth/components/verify-two-factor/verify-two-factor.component.scss b/src/app/features/settings/account-settings/components/two-factor-auth/components/verify-two-factor/verify-two-factor.component.scss
new file mode 100644
index 000000000..b2606ff4d
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/two-factor-auth/components/verify-two-factor/verify-two-factor.component.scss
@@ -0,0 +1,5 @@
+@use "../../../../account-settings.component.scss" as account-settings;
+
+:host {
+ @extend .account-setting;
+}
diff --git a/src/app/features/settings/account-settings/components/two-factor-auth/components/verify-two-factor/verify-two-factor.component.spec.ts b/src/app/features/settings/account-settings/components/two-factor-auth/components/verify-two-factor/verify-two-factor.component.spec.ts
new file mode 100644
index 000000000..e8557132d
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/two-factor-auth/components/verify-two-factor/verify-two-factor.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { VerifyTwoFactorComponent } from './verify-two-factor.component';
+
+describe('VerifyTwoFactorComponent', () => {
+ let component: VerifyTwoFactorComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [VerifyTwoFactorComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(VerifyTwoFactorComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/features/settings/account-settings/components/two-factor-auth/components/verify-two-factor/verify-two-factor.component.ts b/src/app/features/settings/account-settings/components/two-factor-auth/components/verify-two-factor/verify-two-factor.component.ts
new file mode 100644
index 000000000..edac5242e
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/two-factor-auth/components/verify-two-factor/verify-two-factor.component.ts
@@ -0,0 +1,28 @@
+import { Store } from '@ngxs/store';
+
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { Button } from 'primeng/button';
+import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
+
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+
+import { DisableTwoFactorAuth } from '@osf/features/settings/account-settings/store/account-settings.actions';
+
+@Component({
+ selector: 'osf-verify-two-factor',
+ imports: [Button, TranslatePipe],
+ templateUrl: './verify-two-factor.component.html',
+ styleUrl: './verify-two-factor.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class VerifyTwoFactorComponent {
+ #store = inject(Store);
+ dialogRef = inject(DynamicDialogRef);
+ readonly config = inject(DynamicDialogConfig);
+
+ disableTwoFactor() {
+ this.#store.dispatch(DisableTwoFactorAuth);
+ this.dialogRef.close();
+ }
+}
diff --git a/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.html b/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.html
new file mode 100644
index 000000000..6ba2ca2c4
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.html
@@ -0,0 +1,72 @@
+@if (this.accountSettings()) {
+
+
+
+ @if (accountSettings()?.twoFactorEnabled && !accountSettings()?.twoFactorConfirmed) {
+
+ {{ 'settings.accountSettings.twoFactorAuth.description.enabled' | translate }}
+
+
+ {{ 'settings.accountSettings.twoFactorAuth.description.warning' | translate }}
+
+
+ {{ 'settings.accountSettings.twoFactorAuth.description.setup' | translate }}
+
+
+ {{ 'settings.accountSettings.twoFactorAuth.description.verification' | translate }}
+
+
+
+
+ {{ 'settings.accountSettings.twoFactorAuth.verification.label' | translate }}
+
+
+ @if (errorMessage()) {
+ {{ errorMessage() }}
+ }
+
+
+
+ {{ 'settings.accountSettings.common.buttons.enable' | translate }}
+
+
+
+ {{ 'settings.accountSettings.common.buttons.cancel' | translate }}
+
+
+ } @else if (accountSettings()?.twoFactorEnabled && accountSettings()?.twoFactorConfirmed) {
+
+ {{ 'settings.accountSettings.twoFactorAuth.description.enabled' | translate }}
+
+
+
+ } @else {
+
+ {{ 'settings.accountSettings.twoFactorAuth.description.disabled' | translate }}
+
+
+
+
+ {{ 'settings.accountSettings.common.buttons.configure' | translate }}
+
+
+ }
+
+}
diff --git a/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.scss b/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.scss
new file mode 100644
index 000000000..c508e9f25
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.scss
@@ -0,0 +1,11 @@
+@use "../../account-settings.component.scss" as account-settings;
+@use "assets/styles/variables" as var;
+
+:host {
+ @extend .account-setting;
+
+ ::ng-deep .token {
+ color: var.$red-1;
+ display: inline;
+ }
+}
diff --git a/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.spec.ts b/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.spec.ts
new file mode 100644
index 000000000..d33668379
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { TwoFactorAuthComponent } from './two-factor-auth.component';
+
+describe('TwoFactorAuthComponent', () => {
+ let component: TwoFactorAuthComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TwoFactorAuthComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TwoFactorAuthComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.ts b/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.ts
new file mode 100644
index 000000000..a05164645
--- /dev/null
+++ b/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.ts
@@ -0,0 +1,99 @@
+import { Store } from '@ngxs/store';
+
+import { TranslatePipe, TranslateService } from '@ngx-translate/core';
+
+import { Button } from 'primeng/button';
+import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog';
+import { InputText } from 'primeng/inputtext';
+
+import { HttpErrorResponse } from '@angular/common/http';
+import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
+import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
+
+import { UserSelectors } from '@core/store/user/user.selectors';
+import {
+ ConfigureTwoFactorComponent,
+ VerifyTwoFactorComponent,
+} from '@osf/features/settings/account-settings/components/two-factor-auth/components';
+import { AccountSettings } from '@osf/features/settings/account-settings/models/osf-models/account-settings.model';
+import {
+ DisableTwoFactorAuth,
+ SetAccountSettings,
+} from '@osf/features/settings/account-settings/store/account-settings.actions';
+import { AccountSettingsSelectors } from '@osf/features/settings/account-settings/store/account-settings.selectors';
+
+import { AccountSettingsService } from '../../services';
+
+import { QRCodeComponent } from 'angularx-qrcode';
+
+@Component({
+ selector: 'osf-two-factor-auth',
+ imports: [Button, QRCodeComponent, ReactiveFormsModule, InputText, TranslatePipe],
+ templateUrl: './two-factor-auth.component.html',
+ styleUrl: './two-factor-auth.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class TwoFactorAuthComponent {
+ #store = inject(Store);
+ dialogRef: DynamicDialogRef | null = null;
+ readonly #dialogService = inject(DialogService);
+ readonly #accountSettingsService = inject(AccountSettingsService);
+ readonly #translateService = inject(TranslateService);
+ readonly accountSettings = this.#store.selectSignal(AccountSettingsSelectors.getAccountSettings);
+ readonly currentUser = this.#store.selectSignal(UserSelectors.getCurrentUser);
+
+ qrCodeLink = computed(() => {
+ return `otpauth://totp/OSF:${this.currentUser()?.email}?secret=${this.accountSettings()?.secret}`;
+ });
+
+ verificationCode = new FormControl('', {
+ nonNullable: true,
+ validators: [Validators.required],
+ });
+
+ errorMessage = signal('');
+
+ configureTwoFactorAuth(): void {
+ this.dialogRef = this.#dialogService.open(ConfigureTwoFactorComponent, {
+ width: '520px',
+ focusOnShow: false,
+ header: this.#translateService.instant('settings.accountSettings.twoFactorAuth.dialog.configure.title'),
+ closeOnEscape: true,
+ modal: true,
+ closable: true,
+ data: this.accountSettings(),
+ });
+ }
+
+ openDisableDialog() {
+ this.dialogRef = this.#dialogService.open(VerifyTwoFactorComponent, {
+ width: '520px',
+ focusOnShow: false,
+ header: this.#translateService.instant('settings.accountSettings.twoFactorAuth.dialog.disable.title'),
+ closeOnEscape: true,
+ modal: true,
+ closable: true,
+ });
+ }
+
+ enableTwoFactor(): void {
+ this.#accountSettingsService.updateSettings({ two_factor_verification: this.verificationCode.value }).subscribe({
+ next: (response: AccountSettings) => {
+ this.#store.dispatch(new SetAccountSettings(response));
+ },
+ error: (error: HttpErrorResponse) => {
+ if (error.error?.errors?.[0]?.detail) {
+ this.errorMessage.set(error.error.errors[0].detail);
+ } else {
+ this.errorMessage.set(
+ this.#translateService.instant('settings.accountSettings.twoFactorAuth.verification.error')
+ );
+ }
+ },
+ });
+ }
+
+ disableTwoFactor(): void {
+ this.#store.dispatch(DisableTwoFactorAuth);
+ }
+}
diff --git a/src/app/features/settings/account-settings/deactivate-account/deactivate-account/deactivate-account.component.html b/src/app/features/settings/account-settings/deactivate-account/deactivate-account/deactivate-account.component.html
deleted file mode 100644
index eb014acc5..000000000
--- a/src/app/features/settings/account-settings/deactivate-account/deactivate-account/deactivate-account.component.html
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
Are you sure you want to to deactivate your account?
-
-
-
diff --git a/src/app/features/settings/account-settings/deactivate-account/deactivate-account/deactivate-account.component.scss b/src/app/features/settings/account-settings/deactivate-account/deactivate-account/deactivate-account.component.scss
deleted file mode 100644
index e69de29bb..000000000
diff --git a/src/app/features/settings/account-settings/deactivate-account/deactivate-account/deactivate-account.component.ts b/src/app/features/settings/account-settings/deactivate-account/deactivate-account/deactivate-account.component.ts
deleted file mode 100644
index 84880cbc1..000000000
--- a/src/app/features/settings/account-settings/deactivate-account/deactivate-account/deactivate-account.component.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { Button } from 'primeng/button';
-
-import { ChangeDetectionStrategy, Component } from '@angular/core';
-
-@Component({
- selector: 'osf-deactivate-account',
- imports: [Button],
- templateUrl: './deactivate-account.component.html',
- styleUrl: './deactivate-account.component.scss',
- changeDetection: ChangeDetectionStrategy.OnPush,
-})
-export class DeactivateAccountComponent {}
diff --git a/src/app/features/settings/account-settings/mappers/account-settings.mapper.ts b/src/app/features/settings/account-settings/mappers/account-settings.mapper.ts
new file mode 100644
index 000000000..e701b9f9b
--- /dev/null
+++ b/src/app/features/settings/account-settings/mappers/account-settings.mapper.ts
@@ -0,0 +1,15 @@
+import { ApiData } from '@core/services/json-api/json-api.entity';
+import { AccountSettings } from '@osf/features/settings/account-settings/models/osf-models/account-settings.model';
+import { AccountSettingsResponse } from '@osf/features/settings/account-settings/models/responses/get-account-settings-response.entity';
+
+export function MapAccountSettings(data: ApiData): AccountSettings {
+ return {
+ twoFactorEnabled: data.attributes.two_factor_enabled,
+ twoFactorConfirmed: data.attributes.two_factor_confirmed,
+ subscribeOsfGeneralEmail: data.attributes.subscribe_osf_general_email,
+ subscribeOsfHelpEmail: data.attributes.subscribe_osf_help_email,
+ deactivationRequested: data.attributes.deactivation_requested,
+ contactedDeactivation: data.attributes.contacted_deactivation,
+ secret: data.attributes.secret,
+ };
+}
diff --git a/src/app/features/settings/account-settings/mappers/emails.mapper.ts b/src/app/features/settings/account-settings/mappers/emails.mapper.ts
new file mode 100644
index 000000000..53fa02cab
--- /dev/null
+++ b/src/app/features/settings/account-settings/mappers/emails.mapper.ts
@@ -0,0 +1,22 @@
+import { ApiData } from '@core/services/json-api/json-api.entity';
+import { AccountEmail } from '@osf/features/settings/account-settings/models/osf-models/account-email.model';
+import { AccountEmailResponse } from '@osf/features/settings/account-settings/models/responses/list-emails.entity';
+
+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/external-identities.mapper.ts b/src/app/features/settings/account-settings/mappers/external-identities.mapper.ts
new file mode 100644
index 000000000..c6afe5e03
--- /dev/null
+++ b/src/app/features/settings/account-settings/mappers/external-identities.mapper.ts
@@ -0,0 +1,15 @@
+import { ApiData } from '@core/services/json-api/json-api.entity';
+import { ExternalIdentity } from '@osf/features/settings/account-settings/models/osf-models/external-institution.model';
+import { ExternalIdentityResponse } from '@osf/features/settings/account-settings/models/responses/list-identities-response.entity';
+
+export function MapExternalIdentities(data: ApiData[]): ExternalIdentity[] {
+ const identities: ExternalIdentity[] = [];
+ for (const item of data) {
+ identities.push({
+ id: item.id,
+ externalId: item.attributes.external_id,
+ status: item.attributes.status,
+ });
+ }
+ return identities;
+}
diff --git a/src/app/features/settings/account-settings/mappers/index.ts b/src/app/features/settings/account-settings/mappers/index.ts
new file mode 100644
index 000000000..95afda414
--- /dev/null
+++ b/src/app/features/settings/account-settings/mappers/index.ts
@@ -0,0 +1,4 @@
+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/mappers/regions.mapper.ts b/src/app/features/settings/account-settings/mappers/regions.mapper.ts
new file mode 100644
index 000000000..ff679d0ff
--- /dev/null
+++ b/src/app/features/settings/account-settings/mappers/regions.mapper.ts
@@ -0,0 +1,18 @@
+import { ApiData } from '@core/services/json-api/json-api.entity';
+import { Region } from '@osf/features/settings/account-settings/models/osf-models/region.model';
+
+export function MapRegions(data: ApiData<{ name: string }, null, null>[]): Region[] {
+ const regions: Region[] = [];
+ for (const region of data) {
+ regions.push(MapRegion(region));
+ }
+
+ return regions;
+}
+
+export function MapRegion(data: ApiData<{ name: string }, null, null>): Region {
+ return {
+ id: data.id,
+ name: data.attributes.name,
+ };
+}
diff --git a/src/app/features/settings/account-settings/models/index.ts b/src/app/features/settings/account-settings/models/index.ts
new file mode 100644
index 000000000..9afc872a1
--- /dev/null
+++ b/src/app/features/settings/account-settings/models/index.ts
@@ -0,0 +1,9 @@
+export * from './osf-models/account-email.model';
+export * from './osf-models/account-settings.model';
+export * from './osf-models/external-institution.model';
+export * from './osf-models/region.model';
+export * from './responses/get-account-settings-response.entity';
+export * from './responses/get-email-response.entity';
+export * from './responses/get-regions-response.entity';
+export * from './responses/list-emails.entity';
+export * from './responses/list-identities-response.entity';
diff --git a/src/app/features/settings/account-settings/models/osf-models/account-email.model.ts b/src/app/features/settings/account-settings/models/osf-models/account-email.model.ts
new file mode 100644
index 000000000..982990912
--- /dev/null
+++ b/src/app/features/settings/account-settings/models/osf-models/account-email.model.ts
@@ -0,0 +1,8 @@
+export interface AccountEmail {
+ id: string;
+ emailAddress: string;
+ confirmed: boolean;
+ verified: boolean;
+ primary: boolean;
+ isMerge: boolean;
+}
diff --git a/src/app/features/settings/account-settings/models/osf-models/account-settings.model.ts b/src/app/features/settings/account-settings/models/osf-models/account-settings.model.ts
new file mode 100644
index 000000000..bd6a27ae3
--- /dev/null
+++ b/src/app/features/settings/account-settings/models/osf-models/account-settings.model.ts
@@ -0,0 +1,9 @@
+export interface AccountSettings {
+ twoFactorEnabled: boolean;
+ twoFactorConfirmed: boolean;
+ subscribeOsfGeneralEmail: boolean;
+ subscribeOsfHelpEmail: boolean;
+ deactivationRequested: boolean;
+ contactedDeactivation: boolean;
+ secret: string;
+}
diff --git a/src/app/features/settings/account-settings/models/osf-models/external-institution.model.ts b/src/app/features/settings/account-settings/models/osf-models/external-institution.model.ts
new file mode 100644
index 000000000..1f76db107
--- /dev/null
+++ b/src/app/features/settings/account-settings/models/osf-models/external-institution.model.ts
@@ -0,0 +1,5 @@
+export interface ExternalIdentity {
+ id: string;
+ externalId: string;
+ status: string;
+}
diff --git a/src/app/features/settings/account-settings/models/osf-models/region.model.ts b/src/app/features/settings/account-settings/models/osf-models/region.model.ts
new file mode 100644
index 000000000..4068f4f1e
--- /dev/null
+++ b/src/app/features/settings/account-settings/models/osf-models/region.model.ts
@@ -0,0 +1,4 @@
+export interface Region {
+ id: string;
+ name: string;
+}
diff --git a/src/app/features/settings/account-settings/models/responses/get-account-settings-response.entity.ts b/src/app/features/settings/account-settings/models/responses/get-account-settings-response.entity.ts
new file mode 100644
index 000000000..5cab32961
--- /dev/null
+++ b/src/app/features/settings/account-settings/models/responses/get-account-settings-response.entity.ts
@@ -0,0 +1,13 @@
+import { ApiData } from '@core/services/json-api/json-api.entity';
+
+export type GetAccountSettingsResponse = ApiData;
+
+export interface AccountSettingsResponse {
+ two_factor_enabled: boolean;
+ two_factor_confirmed: boolean;
+ subscribe_osf_general_email: boolean;
+ subscribe_osf_help_email: boolean;
+ deactivation_requested: boolean;
+ contacted_deactivation: boolean;
+ secret: string;
+}
diff --git a/src/app/features/settings/account-settings/models/responses/get-email-response.entity.ts b/src/app/features/settings/account-settings/models/responses/get-email-response.entity.ts
new file mode 100644
index 000000000..9a2a957a9
--- /dev/null
+++ b/src/app/features/settings/account-settings/models/responses/get-email-response.entity.ts
@@ -0,0 +1,4 @@
+import { ApiData, JsonApiResponse } from '@core/services/json-api/json-api.entity';
+import { AccountEmailResponse } from '@osf/features/settings/account-settings/models/responses/list-emails.entity';
+
+export type GetEmailResponse = JsonApiResponse, null>;
diff --git a/src/app/features/settings/account-settings/models/responses/get-regions-response.entity.ts b/src/app/features/settings/account-settings/models/responses/get-regions-response.entity.ts
new file mode 100644
index 000000000..099e98a95
--- /dev/null
+++ b/src/app/features/settings/account-settings/models/responses/get-regions-response.entity.ts
@@ -0,0 +1,4 @@
+import { ApiData, JsonApiResponse } from '@core/services/json-api/json-api.entity';
+
+export type GetRegionsResponse = JsonApiResponse[], null>;
+export type GetRegionResponse = JsonApiResponse, null>;
diff --git a/src/app/features/settings/account-settings/models/responses/list-emails.entity.ts b/src/app/features/settings/account-settings/models/responses/list-emails.entity.ts
new file mode 100644
index 000000000..0a74ab0c4
--- /dev/null
+++ b/src/app/features/settings/account-settings/models/responses/list-emails.entity.ts
@@ -0,0 +1,11 @@
+import { ApiData, JsonApiResponse } from '@core/services/json-api/json-api.entity';
+
+export type ListEmailsResponse = JsonApiResponse[], null>;
+
+export interface AccountEmailResponse {
+ email_address: string;
+ confirmed: boolean;
+ verified: boolean;
+ primary: boolean;
+ is_merge: boolean;
+}
diff --git a/src/app/features/settings/account-settings/models/responses/list-identities-response.entity.ts b/src/app/features/settings/account-settings/models/responses/list-identities-response.entity.ts
new file mode 100644
index 000000000..f2c2af49a
--- /dev/null
+++ b/src/app/features/settings/account-settings/models/responses/list-identities-response.entity.ts
@@ -0,0 +1,8 @@
+import { ApiData, JsonApiResponse } from '@core/services/json-api/json-api.entity';
+
+export type ListIdentitiesResponse = JsonApiResponse[], null>;
+
+export interface ExternalIdentityResponse {
+ external_id: string;
+ status: string;
+}
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
new file mode 100644
index 000000000..aed1af89d
--- /dev/null
+++ b/src/app/features/settings/account-settings/services/account-settings.service.ts
@@ -0,0 +1,229 @@
+import { Store } from '@ngxs/store';
+
+import { map, Observable } from 'rxjs';
+
+import { inject, Injectable } from '@angular/core';
+
+import { JsonApiService } from '@core/services/json-api/json-api.service';
+import { UserUS } from '@core/services/json-api/underscore-entites/user/user-us.entity';
+import { mapUserUStoUser } from '@core/services/mappers/users/users.mapper';
+import { User } from '@core/services/user/user.entity';
+import { UserSelectors } from '@core/store/user/user.selectors';
+import { ApiData, JsonApiResponse } from '@osf/core/services/json-api/json-api.entity';
+
+import { environment } from '../../../../../environments/environment';
+import { MapAccountSettings, MapEmail, MapEmails, MapExternalIdentities, MapRegions } from '../mappers';
+import {
+ AccountEmail,
+ AccountEmailResponse,
+ AccountSettings,
+ ExternalIdentity,
+ GetAccountSettingsResponse,
+ GetEmailResponse,
+ GetRegionsResponse,
+ ListEmailsResponse,
+ ListIdentitiesResponse,
+ Region,
+} from '../models';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class AccountSettingsService {
+ #store = inject(Store);
+ #jsonApiService = inject(JsonApiService);
+ #currentUser = this.#store.selectSignal(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<
+ ApiData
+ >(`${environment.apiUrl}/users/${this.#currentUser()?.id}/settings/emails/`, body)
+ .pipe(map((response) => MapEmail(response)));
+ }
+
+ deleteEmail(emailId: string): Observable {
+ return this.#jsonApiService.delete(
+ `${environment.apiUrl}/users/${this.#currentUser()?.id}/settings/emails/${emailId}`
+ );
+ }
+
+ 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
+ .get(`${environment.apiUrl}/regions/`)
+ .pipe(map((response) => MapRegions(response.data)));
+ }
+
+ updateLocation(locationId: string): Observable {
+ const body = {
+ data: {
+ id: this.#currentUser()?.id,
+ attributes: {},
+ relationships: {
+ default_region: {
+ data: {
+ type: 'regions',
+ id: locationId,
+ },
+ },
+ },
+ type: 'users',
+ },
+ };
+
+ return this.#jsonApiService
+ .patch(`${environment.apiUrl}/users/${this.#currentUser()?.id}`, body)
+ .pipe(map((user) => mapUserUStoUser(user)));
+ }
+
+ updateIndexing(allowIndexing: boolean): Observable {
+ const body = {
+ data: {
+ id: this.#currentUser()?.id,
+ attributes: {
+ allow_indexing: allowIndexing,
+ },
+ relationships: {},
+ type: 'users',
+ },
+ };
+
+ return this.#jsonApiService
+ .patch(`${environment.apiUrl}/users/${this.#currentUser()?.id}`, body)
+ .pipe(map((user) => mapUserUStoUser(user)));
+ }
+
+ updatePassword(oldPassword: string, newPassword: string): Observable {
+ const body = {
+ data: {
+ type: 'user_passwords',
+ attributes: {
+ existing_password: oldPassword,
+ new_password: newPassword,
+ },
+ },
+ };
+
+ return this.#jsonApiService.post(`${environment.apiUrl}/users/${this.#currentUser()?.id}/settings/password`, body);
+ }
+
+ getExternalIdentities(): Observable {
+ const params: Record = {
+ page: '1',
+ 'page[size]': '10',
+ };
+
+ return this.#jsonApiService
+ .get(`${environment.apiUrl}/users/me/settings/identities/`, params)
+ .pipe(map((response) => MapExternalIdentities(response.data)));
+ }
+
+ deleteExternalIdentity(id: string): Observable {
+ 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`)
+ .pipe(map((response) => MapAccountSettings(response.data)));
+ }
+
+ updateSettings(settings: Record): Observable {
+ const body = {
+ data: {
+ id: this.#currentUser()?.id,
+ attributes: settings,
+ relationships: {},
+ type: 'user_settings',
+ },
+ };
+
+ return this.#jsonApiService
+ .patch(`${environment.apiUrl}/users/${this.#currentUser()?.id}/settings`, body)
+ .pipe(map((response) => MapAccountSettings(response)));
+ }
+}
diff --git a/src/app/features/settings/account-settings/services/index.ts b/src/app/features/settings/account-settings/services/index.ts
new file mode 100644
index 000000000..f559ff15a
--- /dev/null
+++ b/src/app/features/settings/account-settings/services/index.ts
@@ -0,0 +1 @@
+export * from './account-settings.service';
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
new file mode 100644
index 000000000..32a9a28c9
--- /dev/null
+++ b/src/app/features/settings/account-settings/store/account-settings.actions.ts
@@ -0,0 +1,109 @@
+import { AccountSettings } from '@osf/features/settings/account-settings/models/osf-models/account-settings.model';
+
+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 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 userId: string) {}
+}
+
+export class GetRegions {
+ static readonly type = '[AccountSettings] Get Regions';
+}
+
+export class UpdateRegion {
+ static readonly type = '[AccountSettings] Update Region';
+
+ constructor(public regionId: string) {}
+}
+
+export class UpdateIndexing {
+ static readonly type = '[AccountSettings] Update Indexing';
+
+ constructor(public allowIndexing: boolean) {}
+}
+
+export class GetExternalIdentities {
+ static readonly type = '[AccountSettings] Get External Identities';
+}
+
+export class DeleteExternalIdentity {
+ static readonly type = '[AccountSettings] Delete ExternalIdentities';
+
+ constructor(public externalId: string) {}
+}
+
+export class GetUserInstitutions {
+ static readonly type = '[AccountSettings] Get User Institutions';
+}
+
+export class DeleteUserInstitution {
+ static readonly type = '[AccountSettings] Delete User Institution';
+
+ constructor(
+ public id: string,
+ public userId: string
+ ) {}
+}
+
+export class GetAccountSettings {
+ static readonly type = '[AccountSettings] Get AccountSettings';
+}
+
+export class UpdateAccountSettings {
+ static readonly type = '[AccountSettings] Update Account Settings';
+
+ constructor(public accountSettings: Record) {}
+}
+
+export class DisableTwoFactorAuth {
+ static readonly type = '[AccountSettings] Disable Two-Factor Auth';
+}
+
+export class EnableTwoFactorAuth {
+ static readonly type = '[AccountSettings] Enable Two-Factor Auth';
+}
+
+export class VerifyTwoFactorAuth {
+ static readonly type = '[AccountSettings] Verify Two-Factor Auth';
+
+ constructor(public code: string) {}
+}
+
+export class SetAccountSettings {
+ static readonly type = '[AccountSettings] SetAccountSettings';
+
+ constructor(public accountSettings: AccountSettings) {}
+}
+
+export class DeactivateAccount {
+ static readonly type = '[AccountSettings] Deactivate Account';
+}
+
+export class CancelDeactivationRequest {
+ static readonly type = '[AccountSettings] Cancel Deactivation Request';
+}
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
new file mode 100644
index 000000000..8e6261074
--- /dev/null
+++ b/src/app/features/settings/account-settings/store/account-settings.model.ts
@@ -0,0 +1,14 @@
+import { Institution } from '@osf/features/institutions/entities/institutions.models';
+import { AccountEmail } from '@osf/features/settings/account-settings/models/osf-models/account-email.model';
+import { AccountSettings } from '@osf/features/settings/account-settings/models/osf-models/account-settings.model';
+import { ExternalIdentity } from '@osf/features/settings/account-settings/models/osf-models/external-institution.model';
+import { Region } from '@osf/features/settings/account-settings/models/osf-models/region.model';
+
+export interface AccountSettingsStateModel {
+ emails: AccountEmail[];
+ emailsLoading: boolean;
+ regions: Region[];
+ externalIdentities: ExternalIdentity[];
+ accountSettings: AccountSettings;
+ userInstitutions: Institution[];
+}
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
new file mode 100644
index 000000000..6bbcc67f2
--- /dev/null
+++ b/src/app/features/settings/account-settings/store/account-settings.selectors.ts
@@ -0,0 +1,51 @@
+import { Selector } from '@ngxs/store';
+
+import { Institution } from '@osf/features/institutions/entities/institutions.models';
+import { AccountEmail } from '@osf/features/settings/account-settings/models/osf-models/account-email.model';
+import { AccountSettings } from '@osf/features/settings/account-settings/models/osf-models/account-settings.model';
+import { ExternalIdentity } from '@osf/features/settings/account-settings/models/osf-models/external-institution.model';
+import { Region } from '@osf/features/settings/account-settings/models/osf-models/region.model';
+import { AccountSettingsStateModel } from '@osf/features/settings/account-settings/store/account-settings.model';
+import { AccountSettingsState } from '@osf/features/settings/account-settings/store/account-settings.state';
+
+export class AccountSettingsSelectors {
+ @Selector([AccountSettingsState])
+ static getEmails(state: AccountSettingsStateModel): AccountEmail[] {
+ return state.emails;
+ }
+
+ @Selector([AccountSettingsState])
+ static isEmailsLoading(state: AccountSettingsStateModel): boolean {
+ return state.emailsLoading;
+ }
+
+ @Selector([AccountSettingsState])
+ static getRegions(state: AccountSettingsStateModel): Region[] {
+ return state.regions;
+ }
+
+ @Selector([AccountSettingsState])
+ static getExternalIdentities(state: AccountSettingsStateModel): ExternalIdentity[] {
+ return state.externalIdentities;
+ }
+
+ @Selector([AccountSettingsState])
+ static getAccountSettings(state: AccountSettingsStateModel): AccountSettings | undefined {
+ return state.accountSettings;
+ }
+
+ @Selector([AccountSettingsState])
+ static getTwoFactorEnabled(state: AccountSettingsStateModel): boolean {
+ return state.accountSettings?.twoFactorEnabled ?? false;
+ }
+
+ @Selector([AccountSettingsState])
+ static getTwoFactorSecret(state: AccountSettingsStateModel): string {
+ return state.accountSettings?.secret ?? '';
+ }
+
+ @Selector([AccountSettingsState])
+ static getUserInstitutions(state: AccountSettingsStateModel): Institution[] {
+ return state.userInstitutions;
+ }
+}
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
new file mode 100644
index 000000000..ad696c880
--- /dev/null
+++ b/src/app/features/settings/account-settings/store/account-settings.state.ts
@@ -0,0 +1,272 @@
+import { Action, State, StateContext } from '@ngxs/store';
+
+import { finalize, tap } from 'rxjs';
+
+import { inject, Injectable } from '@angular/core';
+
+import { SetCurrentUser } from '@core/store/user';
+import { InstitutionsService } from '@osf/features/institutions/institutions.service';
+import {
+ AddEmail,
+ CancelDeactivationRequest,
+ DeactivateAccount,
+ DeleteEmail,
+ DeleteExternalIdentity,
+ DeleteUserInstitution,
+ DisableTwoFactorAuth,
+ EnableTwoFactorAuth,
+ GetAccountSettings,
+ GetEmails,
+ GetExternalIdentities,
+ GetRegions,
+ GetUserInstitutions,
+ MakePrimary,
+ SetAccountSettings,
+ UpdateAccountSettings,
+ UpdateIndexing,
+ UpdateRegion,
+ VerifyEmail,
+} from '@osf/features/settings/account-settings/store/account-settings.actions';
+import { AccountSettingsStateModel } from '@osf/features/settings/account-settings/store/account-settings.model';
+
+import { AccountSettingsService } from '../services';
+
+@Injectable()
+@State({
+ name: 'accountSettings',
+ defaults: {
+ emails: [],
+ emailsLoading: false,
+ regions: [],
+ externalIdentities: [],
+ accountSettings: {
+ twoFactorEnabled: false,
+ twoFactorConfirmed: false,
+ subscribeOsfGeneralEmail: false,
+ subscribeOsfHelpEmail: false,
+ deactivationRequested: false,
+ contactedDeactivation: false,
+ secret: '',
+ },
+ userInstitutions: [],
+ },
+})
+export class AccountSettingsState {
+ #accountSettingsService = inject(AccountSettingsService);
+ #institutionsService = inject(InstitutionsService);
+
+ @Action(GetEmails)
+ getEmails(ctx: StateContext) {
+ ctx.patchState({
+ emailsLoading: true,
+ });
+ return this.#accountSettingsService.getEmails().pipe(
+ tap({
+ next: (emails) => {
+ ctx.patchState({
+ emails: emails,
+ });
+ },
+ }),
+ finalize(() => ctx.patchState({ emailsLoading: false }))
+ );
+ }
+
+ @Action(AddEmail)
+ addEmail(ctx: StateContext, action: AddEmail) {
+ return this.#accountSettingsService.addEmail(action.email).pipe(
+ tap((email) => {
+ if (email.emailAddress && !email.confirmed) {
+ ctx.patchState({
+ emails: [email, ...ctx.getState().emails],
+ });
+ }
+ })
+ );
+ }
+
+ @Action(DeleteEmail)
+ deleteEmail(ctx: StateContext, action: DeleteEmail) {
+ return this.#accountSettingsService.deleteEmail(action.email).pipe(
+ tap({
+ next: () => {
+ const state = ctx.getState();
+ ctx.setState({
+ ...state,
+ emails: state.emails.filter((email) => email.id !== action.email),
+ });
+ },
+ })
+ );
+ }
+
+ @Action(VerifyEmail)
+ verifyEmail(ctx: StateContext, action: VerifyEmail) {
+ return this.#accountSettingsService.verifyEmail(action.userId, action.emailId).pipe(
+ tap((email) => {
+ if (email.verified) {
+ ctx.dispatch(new GetEmails());
+ }
+ })
+ );
+ }
+
+ @Action(MakePrimary)
+ makePrimary(ctx: StateContext, action: MakePrimary) {
+ return this.#accountSettingsService.makePrimary(action.userId).pipe(
+ tap((email) => {
+ if (email.verified) {
+ ctx.dispatch(new GetEmails());
+ }
+ })
+ );
+ }
+
+ @Action(GetRegions)
+ getRegions(ctx: StateContext) {
+ return this.#accountSettingsService.getRegions().pipe(
+ tap({
+ next: (regions) => ctx.patchState({ regions: regions }),
+ })
+ );
+ }
+
+ @Action(UpdateRegion)
+ updateRegion(ctx: StateContext, action: UpdateRegion) {
+ return this.#accountSettingsService.updateLocation(action.regionId).pipe(
+ tap({
+ next: (user) => {
+ ctx.dispatch(new SetCurrentUser(user));
+ },
+ })
+ );
+ }
+
+ @Action(UpdateIndexing)
+ updateIndexing(ctx: StateContext, action: UpdateIndexing) {
+ return this.#accountSettingsService.updateIndexing(action.allowIndexing).pipe(
+ tap({
+ next: (user) => {
+ ctx.dispatch(new SetCurrentUser(user));
+ },
+ })
+ );
+ }
+
+ @Action(GetExternalIdentities)
+ getExternalIdentities(ctx: StateContext) {
+ return this.#accountSettingsService.getExternalIdentities().pipe(
+ tap({
+ next: (identities) => ctx.patchState({ externalIdentities: identities }),
+ })
+ );
+ }
+
+ @Action(DeleteExternalIdentity)
+ deleteExternalIdentity(ctx: StateContext, action: DeleteExternalIdentity) {
+ return this.#accountSettingsService.deleteExternalIdentity(action.externalId).pipe(
+ tap(() => {
+ ctx.dispatch(GetExternalIdentities);
+ })
+ );
+ }
+
+ @Action(GetUserInstitutions)
+ getUserInstitutions(ctx: StateContext) {
+ return this.#institutionsService
+ .getUserInstitutions()
+ .pipe(tap((userInstitutions) => ctx.patchState({ userInstitutions })));
+ }
+
+ @Action(DeleteUserInstitution)
+ deleteUserInstitution(ctx: StateContext, action: DeleteUserInstitution) {
+ return this.#institutionsService.deleteUserInstitution(action.id, action.userId).pipe(
+ tap(() => {
+ ctx.dispatch(GetUserInstitutions);
+ })
+ );
+ }
+
+ @Action(GetAccountSettings)
+ getAccountSettings(ctx: StateContext) {
+ return this.#accountSettingsService.getSettings().pipe(
+ tap({
+ next: (settings) => {
+ ctx.patchState({
+ accountSettings: settings,
+ });
+ },
+ })
+ );
+ }
+
+ @Action(UpdateAccountSettings)
+ updateAccountSettings(ctx: StateContext, action: UpdateAccountSettings) {
+ return this.#accountSettingsService.updateSettings(action.accountSettings).pipe(
+ tap({
+ next: (settings) => {
+ ctx.patchState({
+ accountSettings: settings,
+ });
+ },
+ })
+ );
+ }
+
+ @Action(DisableTwoFactorAuth)
+ disableTwoFactorAuth(ctx: StateContext) {
+ return this.#accountSettingsService.updateSettings({ two_factor_enabled: 'false' }).pipe(
+ tap({
+ next: (settings) => {
+ ctx.patchState({
+ accountSettings: settings,
+ });
+ },
+ })
+ );
+ }
+
+ @Action(EnableTwoFactorAuth)
+ enableTwoFactorAuth(ctx: StateContext) {
+ return this.#accountSettingsService.updateSettings({ two_factor_enabled: 'true' }).pipe(
+ tap({
+ next: (settings) => {
+ ctx.patchState({
+ accountSettings: settings,
+ });
+ },
+ })
+ );
+ }
+
+ @Action(SetAccountSettings)
+ setAccountSettings(ctx: StateContext, action: SetAccountSettings) {
+ ctx.patchState({ accountSettings: action.accountSettings });
+ }
+
+ @Action(DeactivateAccount)
+ deactivateAccount(ctx: StateContext) {
+ return this.#accountSettingsService.updateSettings({ deactivation_requested: 'true' }).pipe(
+ tap({
+ next: (settings) => {
+ ctx.patchState({
+ accountSettings: settings,
+ });
+ },
+ })
+ );
+ }
+
+ @Action(CancelDeactivationRequest)
+ cancelDeactivationRequest(ctx: StateContext) {
+ return this.#accountSettingsService.updateSettings({ deactivation_requested: 'false' }).pipe(
+ tap({
+ next: (settings) => {
+ ctx.patchState({
+ accountSettings: settings,
+ });
+ },
+ })
+ );
+ }
+}
diff --git a/src/app/features/settings/index.ts b/src/app/features/settings/index.ts
index 3cd1783d9..3c33a3305 100644
--- a/src/app/features/settings/index.ts
+++ b/src/app/features/settings/index.ts
@@ -1,3 +1,2 @@
export * from './account-settings/account-settings.component';
-export * from './account-settings/account-settings.route';
export * from './profile-settings/profile-settings.component';
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index ae8c84dd1..ba7f1fbf3 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -164,6 +164,15 @@
"storage": "Storage"
}
}
+ },
+ "confirmEmail": {
+ "title": "Add alternative email",
+ "description": "Would you like to add ",
+ "description2": " to your account?",
+ "buttons": {
+ "doNotAdd": "Do not add email",
+ "addEmail": "Add email"
+ }
}
},
"myProjects": {
@@ -461,6 +470,158 @@
"discardChanges": "Discard Changes",
"save": "Save"
}
+ },
+ "accountSettings": {
+ "common": {
+ "buttons": {
+ "cancel": "Cancel",
+ "configure": "Configure",
+ "disable": "Disable",
+ "enable": "Enable",
+ "undo": "Undo",
+ "request": "Request"
+ }
+ },
+ "deactivateAccount": {
+ "title": "Deactivate Account",
+ "warning": {
+ "title": "Warning: This action cannot be undone once approved.",
+ "confirm": "Are you sure you want to request account deactivation?",
+ "description": "An OSF administrator will review your request. If accepted, you will NOT be able to reactivate your account."
+ },
+ "description": {
+ "main": "Deactivating your account will remove you from all public projects to which you are a contributor. Your account will no longer be associated with OSF projects, and your work on the OSF will be inaccessible.",
+ "secondary": "If this is a secondary account that you want to close, consider merging your accounts"
+ },
+ "pendingDeactivation": "Your account is currently pending deactivation.",
+ "actions": {
+ "undoDeactivation": "Undo deactivation request",
+ "requestDeactivation": "Request deactivation"
+ },
+ "dialog": {
+ "deactivate": {
+ "title": "Deactivate account"
+ },
+ "undo": {
+ "title": "Undo deactivation request?"
+ }
+ }
+ },
+ "affiliatedInstitutions": {
+ "title": "Affiliated Institutions",
+ "description": "Connect your account to institutions to access institutional features and services.",
+ "noInstitutions": "You have no affiliations."
+ },
+ "addEmail": {
+ "title": "Add alternative email",
+ "description": "Add an alternative email address to your account.",
+ "form": {
+ "email": "Email",
+ "emailPlaceholder": "email@example.com"
+ },
+ "buttons": {
+ "cancel": "Cancel",
+ "add": "Add"
+ },
+ "messages": {
+ "success": "Alternative email added successfully",
+ "error": "Failed to add alternative email"
+ }
+ },
+ "changePassword": {
+ "title": "Change Password",
+ "form": {
+ "oldPassword": "Old password",
+ "newPassword": "New password",
+ "confirmPassword": "Confirm password",
+ "passwordRequirements": "Your password needs to be at least 8 characters long, include both lower- and upper-case characters, and have at least one number or special character"
+ },
+ "validation": {
+ "oldPasswordRequired": "Old password is required",
+ "newPasswordRequired": "New password is required",
+ "confirmPasswordRequired": "Please confirm your password",
+ "passwordsDoNotMatch": "Passwords do not match",
+ "sameAsOldPassword": "New password must be different from old password"
+ },
+ "buttons": {
+ "update": "Update"
+ },
+ "messages": {
+ "error": "Failed to update password. Please try again."
+ }
+ },
+ "connectedEmails": {
+ "title": "Connected Emails",
+ "description": "To merge an existing account with this one or to log in with multiple email addresses, add an alternate email address below. All projects and components will be displayed under the email address listed as primary.",
+ "primaryEmail": "Primary Email:",
+ "alternateEmails": "Alternate Emails:",
+ "unconfirmedEmails": "Unconfirmed emails:",
+ "buttons": {
+ "makePrimary": "Make Primary",
+ "resend": "Resend",
+ "resendConfirmation": "Resend confirmation",
+ "addEmail": "Add Email"
+ },
+ "dialog": {
+ "title": "Add alternative email"
+ }
+ },
+ "connectedIdentities": {
+ "title": "Connected identities",
+ "description": "Connected identities allow you to log in to the OSF via a third-party service. You can revoke these identifies.",
+ "noIdentities": "You have not authorized any external services to log in to the OSF."
+ },
+ "defaultStorageLocation": {
+ "title": "Default storage location",
+ "description": "This location will be applied to new projects and components. It will not affect existing projects and components.",
+ "buttons": {
+ "update": "Update Location"
+ }
+ },
+ "shareIndexing": {
+ "title": "Opt out of SHARE indexing",
+ "description": "By default, OSF users are indexed into SHARE, a free, open dataset of research metadata. This allows SHARE to include your user profile and research in its database, which is used by search engines and other services to make research more discoverable. You can opt out of this indexing by checking the box below. NOTE: Public projects, files, registrations, and preprints will still be indexed in SHARE.",
+ "learnMore": "Learn more about SHARE",
+ "options": {
+ "optOut": "Out of SHARE Indexing",
+ "optIn": "Opt In To SHARE Indexing"
+ },
+ "buttons": {
+ "update": "Update"
+ }
+ },
+ "twoFactorAuth": {
+ "title": "Two-factor authentication",
+ "description": {
+ "enabled": "By using two-factor authentication, you will protect your OSF account with both your password and your mobile phone.",
+ "warning": "Important: If you lose access to your mobile device, you will not be able to log in to your OSF account.",
+ "setup": "To use, you must install an appropriate application on your mobile device. Google Authenticator is a popular choice and is available for both Android and iOS.",
+ "verification": "Once verified, your device will display a six-digit code that must be entered during the login process. This code changes every few seconds, which means that unauthorized users will not be able to log in to you account, even if they know your password.",
+ "scan": "Scan the image below, or enter the secret key {{secret}}
into your authentication device.",
+ "disabled": "Two-factor authentication protects your OSF account using both your password and email."
+ },
+ "verification": {
+ "label": "Enter your verification code:",
+ "error": "Verification code is invalid. Please try again."
+ },
+ "dialog": {
+ "configure": {
+ "title": "Configure"
+ },
+ "disable": {
+ "title": "Disable"
+ }
+ },
+ "configure": {
+ "description": {
+ "main": "Configuring two-factor authentication will not immediately activate this feature for your account.",
+ "steps": "You will need to follow the steps that appear below to complete the activation of two-factor authentication for your account."
+ }
+ },
+ "verify": {
+ "title": "Are you sure you want to disable two-factor authentication?"
+ }
+ }
}
},
"footer": {
@@ -482,4 +643,4 @@
},
"copyright": "Copyright © 2011-2025"
}
-}
+}
\ No newline at end of file
diff --git a/src/assets/icons/source/arrow-down.svg b/src/assets/icons/source/arrow-down.svg
index 44f309f17..9adabdd7f 100644
--- a/src/assets/icons/source/arrow-down.svg
+++ b/src/assets/icons/source/arrow-down.svg
@@ -1,5 +1,3 @@
-
-
-
+
+
-
diff --git a/src/assets/icons/source/warning-sign.svg b/src/assets/icons/source/warning-sign.svg
index 2da513414..96c555c55 100644
--- a/src/assets/icons/source/warning-sign.svg
+++ b/src/assets/icons/source/warning-sign.svg
@@ -1,5 +1,3 @@
-
-
-
+
+
-
diff --git a/src/assets/styles/overrides/message.scss b/src/assets/styles/overrides/message.scss
index cdaad5d21..a54bf305e 100644
--- a/src/assets/styles/overrides/message.scss
+++ b/src/assets/styles/overrides/message.scss
@@ -17,3 +17,17 @@
.p-message-error {
background-color: var.$red-1;
}
+
+.warning-message {
+ .p-message {
+ width: fit-content;
+ height: fit-content;
+ color: var.$red-1;
+ background: var.$red-2;
+ padding: 1.7rem;
+
+ .p-message-text {
+ font-weight: 700;
+ }
+ }
+}
diff --git a/src/assets/styles/overrides/password.scss b/src/assets/styles/overrides/password.scss
new file mode 100644
index 000000000..f4047de5f
--- /dev/null
+++ b/src/assets/styles/overrides/password.scss
@@ -0,0 +1,7 @@
+.no-meter {
+ .p-password {
+ .p-password-overlay {
+ display: none;
+ }
+ }
+}
diff --git a/src/assets/styles/overrides/spinner.scss b/src/assets/styles/overrides/spinner.scss
new file mode 100644
index 000000000..792ba84e3
--- /dev/null
+++ b/src/assets/styles/overrides/spinner.scss
@@ -0,0 +1,5 @@
+@use "assets/styles/variables" as var;
+
+.p-progressspinner-circle {
+ stroke: var.$pr-blue-1 !important;
+}
diff --git a/src/assets/styles/styles.scss b/src/assets/styles/styles.scss
index 7120cc0b7..01460d575 100644
--- a/src/assets/styles/styles.scss
+++ b/src/assets/styles/styles.scss
@@ -28,6 +28,9 @@
@use "./overrides/tag";
@use "./overrides/dataview";
@use "./overrides/menu";
+@use "./overrides/spinner";
+@use "./overrides/password";
+
@layer base, primeng, reset;
@layer base {