diff --git a/src/app/core/services/user.service.ts b/src/app/core/services/user.service.ts index ddff1e871..2db3cdcd5 100644 --- a/src/app/core/services/user.service.ts +++ b/src/app/core/services/user.service.ts @@ -7,7 +7,7 @@ import { UserMapper } from '@osf/shared/mappers'; import { JsonApiResponse, ProfileSettingsUpdate, - User, + User, UserAcceptedTermsOfServiceJsonApi, UserData, UserDataJsonApi, UserDataResponseJsonApi, @@ -53,7 +53,8 @@ export class UserService { } updateUserProfile(userId: string, key: string, data: ProfileSettingsUpdate): Observable { - const patchedData = key === ProfileSettingsKey.User ? data : { [key]: data }; + let data_formatted = ProfileSettingsKey.User && data.hasOwnProperty('acceptedTermsOfService') ? {accepted_terms_of_service: true} : data; + const patchedData = key === ProfileSettingsKey.User ? data_formatted : { [key]: data_formatted }; return this.jsonApiService .patch(`${this.apiUrl}/users/${userId}/`, { @@ -61,4 +62,12 @@ export class UserService { }) .pipe(map((response) => UserMapper.fromUserGetResponse(response))); } + + updateUserAcceptedTermsOfService(userId: string, data: UserAcceptedTermsOfServiceJsonApi): Observable { + return this.jsonApiService + .patch(`${this.apiUrl}/users/${userId}/`, { + data: { type: 'users', id: userId, attributes: data }, + }) + .pipe(map((response) => UserMapper.fromUserGetResponse(response))); + } } diff --git a/src/app/core/store/user/user.actions.ts b/src/app/core/store/user/user.actions.ts index 716f9c09f..591903ade 100644 --- a/src/app/core/store/user/user.actions.ts +++ b/src/app/core/store/user/user.actions.ts @@ -49,6 +49,10 @@ export class SetUserAsModerator { static readonly type = '[User] Set User As Moderator'; } +export class AcceptTermsOfServiceByUser { + static readonly type = '[User] Accept Terms Of Service'; +} + export class ClearCurrentUser { static readonly type = '[User] Clear Current User'; } diff --git a/src/app/core/store/user/user.state.ts b/src/app/core/store/user/user.state.ts index 1067d11ef..da41b3e23 100644 --- a/src/app/core/store/user/user.state.ts +++ b/src/app/core/store/user/user.state.ts @@ -8,11 +8,12 @@ import { inject, Injectable } from '@angular/core'; import { ProfileSettingsKey } from '@osf/shared/enums'; import { removeNullable } from '@osf/shared/helpers'; import { UserMapper } from '@osf/shared/mappers'; -import { Social } from '@osf/shared/models'; +import { Social, User } from '@osf/shared/models'; import { UserService } from '../../services'; import { + AcceptTermsOfServiceByUser, ClearCurrentUser, GetCurrentUser, GetCurrentUserSettings, @@ -253,6 +254,39 @@ export class UserState { }); } + @Action(AcceptTermsOfServiceByUser) + acceptTermsOfServiceByUser(ctx: StateContext) { + const state = ctx.getState(); + const currentUser = state.currentUser.data; + + if (!currentUser) { + return; + } + + const updatePayload: Partial = { + acceptedTermsOfService: true, + }; + const apiRequest = UserMapper.toAcceptedTermsOfServiceRequest(updatePayload); + + return this.userService + .updateUserAcceptedTermsOfService(currentUser.id, apiRequest) + .pipe( + tap((response: User): void => { + if (response.acceptedTermsOfService) { + ctx.patchState({ + currentUser: { + ...state.currentUser, + data: { + ...currentUser, + acceptedTermsOfService: true, + }, + }, + }); + } + }) + ); + } + @Action(ClearCurrentUser) clearCurrentUser(ctx: StateContext) { ctx.patchState({ diff --git a/src/app/features/files/mappers/resource-metadata.mapper.ts b/src/app/features/files/mappers/resource-metadata.mapper.ts index 714965b2a..8e9ed18d2 100644 --- a/src/app/features/files/mappers/resource-metadata.mapper.ts +++ b/src/app/features/files/mappers/resource-metadata.mapper.ts @@ -1,5 +1,5 @@ import { IdentifiersMapper } from '@shared/mappers/identifiers.mapper'; -import { ResourceMetadata } from '@shared/models'; +import { ResourceMetadata } from '@osf/shared/models'; import { GetResourceCustomMetadataResponse } from '../models/get-resource-custom-metadata-response.model'; import { GetResourceShortInfoResponse } from '../models/get-resource-short-info-response.model'; diff --git a/src/app/features/home/components/index.ts b/src/app/features/home/components/index.ts new file mode 100644 index 000000000..d31658307 --- /dev/null +++ b/src/app/features/home/components/index.ts @@ -0,0 +1 @@ +export { TosConsentBannerComponent } from './tos-consent-banner/tos-consent-banner.component'; diff --git a/src/app/features/home/components/tos-consent-banner/tos-consent-banner.component.html b/src/app/features/home/components/tos-consent-banner/tos-consent-banner.component.html new file mode 100644 index 000000000..498edfe9d --- /dev/null +++ b/src/app/features/home/components/tos-consent-banner/tos-consent-banner.component.html @@ -0,0 +1,48 @@ +@if (!acceptedTermsOfServiceChange()) { +
+ + +
+
+ + + + {{ 'toast.tos-consent.message' | translate }} + + {{ 'toast.tos-consent.termsOfUse' | translate }} + + {{ 'toast.tos-consent.and' | translate }} + + {{ 'toast.tos-consent.privacyPolicy' | translate }} + + +
+ +
+
+ + + + +
+ + + +
+
+
+
+
+} diff --git a/src/app/features/home/components/tos-consent-banner/tos-consent-banner.component.scss b/src/app/features/home/components/tos-consent-banner/tos-consent-banner.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/home/components/tos-consent-banner/tos-consent-banner.component.spec.ts b/src/app/features/home/components/tos-consent-banner/tos-consent-banner.component.spec.ts new file mode 100644 index 000000000..e6652e1c0 --- /dev/null +++ b/src/app/features/home/components/tos-consent-banner/tos-consent-banner.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TosConsentBannerComponent } from './tos-consent-banner.component'; + +describe.skip('TosConsentBannerComponent', () => { + let component: TosConsentBannerComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TosConsentBannerComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TosConsentBannerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/home/components/tos-consent-banner/tos-consent-banner.component.ts b/src/app/features/home/components/tos-consent-banner/tos-consent-banner.component.ts new file mode 100644 index 000000000..6035a908e --- /dev/null +++ b/src/app/features/home/components/tos-consent-banner/tos-consent-banner.component.ts @@ -0,0 +1,45 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Checkbox } from 'primeng/checkbox'; +import { Message } from 'primeng/message'; + +import { Component, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; + +import { AcceptTermsOfServiceByUser, UserSelectors } from '@core/store/user'; +import { IconComponent } from '@osf/shared/components'; +import { ToastService } from '@osf/shared/services'; + +@Component({ + selector: 'osf-tos-consent-banner', + imports: [FormsModule, Checkbox, Button, Message, TranslatePipe, IconComponent, RouterLink], + templateUrl: './tos-consent-banner.component.html', + styleUrls: ['./tos-consent-banner.component.scss'], +}) +export class TosConsentBannerComponent { + private readonly toastService = inject(ToastService); + private readonly translateService = inject(TranslateService); + + readonly actions = createDispatchMap({ acceptTermsOfServiceByUser: AcceptTermsOfServiceByUser }); + readonly currentUser = select(UserSelectors.getCurrentUser); + + acceptedTermsOfService = signal(false); + errorMessage: string | null = null; + + acceptedTermsOfServiceChange = computed(() => this.currentUser()?.acceptedTermsOfService); + + onContinue() { + if (!this.acceptedTermsOfService()) { + this.errorMessage = this.translateService.instant('toast.tos-consent.error-message'); + this.toastService.showError(this.errorMessage as string); + return; + } + + this.errorMessage = null; + this.actions.acceptTermsOfServiceByUser(); + } +} diff --git a/src/app/features/home/pages/dashboard/dashboard.component.html b/src/app/features/home/pages/dashboard/dashboard.component.html index 4ba12e2f2..7b88d1c56 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.html +++ b/src/app/features/home/pages/dashboard/dashboard.component.html @@ -10,6 +10,7 @@ [buttonLabel]="'home.loggedIn.dashboard.createProject' | translate" (buttonClick)="createProject()" /> +

@@ -75,6 +76,7 @@

{{ 'home.loggedIn.hosting.title' | translate }}

[buttonLabel]="'home.loggedIn.dashboard.createProject' | translate" (buttonClick)="createProject()" /> +

{{ 'home.loggedIn.dashboard.noCreatedProject' | translate }}

diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts index efde941ef..bdd6fb896 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.ts @@ -28,6 +28,8 @@ import { MyResourcesItem, MyResourcesSearchFilters, TableParameters } from '@osf import { ProjectRedirectDialogService } from '@osf/shared/services'; import { ClearMyResources, GetMyProjects, MyResourcesSelectors } from '@osf/shared/stores'; +import { TosConsentBannerComponent } from '../../components'; + @Component({ selector: 'osf-dashboard', imports: [ @@ -38,6 +40,7 @@ import { ClearMyResources, GetMyProjects, MyResourcesSelectors } from '@osf/shar IconComponent, TranslatePipe, LoadingSpinnerComponent, + TosConsentBannerComponent, ], templateUrl: './dashboard.component.html', styleUrl: './dashboard.component.scss', diff --git a/src/app/shared/components/datacite-tracker/datacite-tracker.component.ts b/src/app/shared/components/datacite-tracker/datacite-tracker.component.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/mappers/user/user.mapper.ts b/src/app/shared/mappers/user/user.mapper.ts index cbf232ec3..0389a7873 100644 --- a/src/app/shared/mappers/user/user.mapper.ts +++ b/src/app/shared/mappers/user/user.mapper.ts @@ -1,5 +1,5 @@ import { - User, + User, UserAcceptedTermsOfServiceJsonApi, UserData, UserDataJsonApi, UserDataResponseJsonApi, @@ -35,6 +35,7 @@ export class UserMapper { defaultRegionId: user.relationships?.default_region?.data?.id, allowIndexing: user.attributes?.allow_indexing, canViewReviews: user.attributes.can_view_reviews === true, // [NS] Do not simplify it + acceptedTermsOfService: user.attributes.accepted_terms_of_service, }; } @@ -67,4 +68,11 @@ export class UserMapper { suffix: name.suffix ?? '', }; } + + static toAcceptedTermsOfServiceRequest(name: Partial): UserAcceptedTermsOfServiceJsonApi { + return { + accepted_terms_of_service: name.acceptedTermsOfService ?? false, + }; + } + } diff --git a/src/app/shared/models/addons/configured-storage-addon.model.ts b/src/app/shared/models/addons/configured-storage-addon.model.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/models/user/user.models.ts b/src/app/shared/models/user/user.models.ts index 25ff7a3fe..49bb58567 100644 --- a/src/app/shared/models/user/user.models.ts +++ b/src/app/shared/models/user/user.models.ts @@ -24,6 +24,7 @@ export interface User { allowIndexing: boolean | undefined; isModerator?: boolean; canViewReviews: boolean; + acceptedTermsOfService: boolean; } export interface UserSettings { @@ -47,6 +48,7 @@ export interface UserDataJsonApi { date_registered: string; allow_indexing?: boolean; can_view_reviews: boolean; + accepted_terms_of_service: boolean; }; relationships: { default_region: { @@ -103,3 +105,7 @@ export interface UserData { activeFlags: string[]; currentUser: User | null; } + +export interface UserAcceptedTermsOfServiceJsonApi { + accepted_terms_of_service: boolean; +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 3cd7ddd87..f0260217b 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -211,6 +211,14 @@ "recentActivity": "Recent Activity" }, "toast": { + "tos-consent": { + "message": "Notice: We've updated our", + "termsOfUse": " terms of use ", + "and": " and ", + "privacyPolicy": " privacy policy.", + "haveReadAndAgree": "I've read and agree to the terms and conditions", + "continue": "Continue" + }, "cookie-consent": { "message": "Notice: This website relies on cookies to help provide a better user experience. By clicking accept or continuing to use the site, you consent to our use of cookies. See our Privacy Policy and Cookie Use for more information.", "accept": "Accept cookies"