diff --git a/src/app/core/components/request-access/request-access.component.ts b/src/app/core/components/request-access/request-access.component.ts index 5a1fb9aac..a6b10d493 100644 --- a/src/app/core/components/request-access/request-access.component.ts +++ b/src/app/core/components/request-access/request-access.component.ts @@ -12,9 +12,9 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { AuthService, RequestAccessService } from '@osf/core/services'; +import { AuthService } from '@core/services'; import { InputLimits } from '@osf/shared/constants'; -import { LoaderService, ToastService } from '@osf/shared/services'; +import { LoaderService, RequestAccessService, ToastService } from '@osf/shared/services'; @Component({ selector: 'osf-request-access', diff --git a/src/app/core/services/index.ts b/src/app/core/services/index.ts index 45baa421e..acb0db14e 100644 --- a/src/app/core/services/index.ts +++ b/src/app/core/services/index.ts @@ -1,4 +1,3 @@ export { AuthService } from './auth.service'; -export { RequestAccessService } from './request-access.service'; export { UserService } from './user.service'; export { UserEmailsService } from './user-emails.service'; diff --git a/src/app/core/services/request-access.service.ts b/src/app/core/services/request-access.service.ts deleted file mode 100644 index 324b701d9..000000000 --- a/src/app/core/services/request-access.service.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Observable } from 'rxjs'; - -import { inject, Injectable } from '@angular/core'; - -import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { JsonApiService } from '@osf/shared/services'; - -@Injectable({ - providedIn: 'root', -}) -export class RequestAccessService { - private readonly jsonApiService = inject(JsonApiService); - private readonly environment = inject(ENVIRONMENT); - - get apiUrl() { - return `${this.environment.apiDomainUrl}/v2`; - } - - requestAccessToProject(projectId: string, comment = ''): Observable { - const payload = { - data: { - attributes: { - comment, - request_type: 'access', - }, - type: 'node-requests', - }, - }; - - return this.jsonApiService.post(`${this.apiUrl}/nodes/${projectId}/requests/`, payload); - } -} diff --git a/src/app/features/admin-institutions/mappers/send-message-request.mapper.ts b/src/app/features/admin-institutions/mappers/send-message-request.mapper.ts index 6668bbb1e..914c48758 100644 --- a/src/app/features/admin-institutions/mappers/send-message-request.mapper.ts +++ b/src/app/features/admin-institutions/mappers/send-message-request.mapper.ts @@ -1,4 +1,4 @@ -import { SendMessageRequest } from '@osf/features/admin-institutions/models'; +import { SendMessageRequest } from '../models'; export function sendMessageRequestMapper(request: SendMessageRequest) { return { diff --git a/src/app/features/project/contributors/contributors.component.html b/src/app/features/project/contributors/contributors.component.html index 802e68def..1780286fd 100644 --- a/src/app/features/project/contributors/contributors.component.html +++ b/src/app/features/project/contributors/contributors.component.html @@ -87,6 +87,21 @@

{{ 'navigation.contributors' | translate } } + @if (showRequestAccessList()) { +

{{ 'project.requestAccess.requestForAccess' | translate }}

+ +

{{ 'project.requestAccess.followingUsers' | translate }}

+ + + } + @if (isCurrentUserAdminContributor()) {

{{ 'project.contributors.viewOnly' | translate }}

diff --git a/src/app/features/project/contributors/contributors.component.ts b/src/app/features/project/contributors/contributors.component.ts index 0b7944c40..c33a00e6c 100644 --- a/src/app/features/project/contributors/contributors.component.ts +++ b/src/app/features/project/contributors/contributors.component.ts @@ -28,6 +28,7 @@ import { AddContributorDialogComponent, AddUnregisteredContributorDialogComponent, ContributorsTableComponent, + RequestAccessTableComponent, } from '@osf/shared/components/contributors'; import { BIBLIOGRAPHY_OPTIONS, PERMISSION_OPTIONS } from '@osf/shared/constants'; import { AddContributorType, ContributorPermission, ResourceType } from '@osf/shared/enums'; @@ -41,6 +42,7 @@ import { } from '@osf/shared/models'; import { CustomConfirmationService, CustomDialogService, ToastService } from '@osf/shared/services'; import { + AcceptRequestAccess, AddContributor, BulkAddContributors, BulkUpdateContributors, @@ -51,7 +53,9 @@ import { DeleteViewOnlyLink, FetchViewOnlyLinks, GetAllContributors, + GetRequestAccessContributors, GetResourceDetails, + RejectRequestAccess, UpdateBibliographyFilter, UpdateContributorsSearchValue, UpdatePermissionFilter, @@ -71,6 +75,7 @@ import { ResourceInfoModel } from './models'; FormsModule, TableModule, ContributorsTableComponent, + RequestAccessTableComponent, ViewOnlyTableComponent, ], templateUrl: './contributors.component.html', @@ -99,9 +104,11 @@ export class ContributorsComponent implements OnInit { readonly permissionsOptions: SelectOption[] = PERMISSION_OPTIONS; readonly bibliographyOptions: SelectOption[] = BIBLIOGRAPHY_OPTIONS; - initialContributors = select(ContributorsSelectors.getContributors); contributors = signal([]); + readonly initialContributors = select(ContributorsSelectors.getContributors); + readonly requestAccessList = select(ContributorsSelectors.getRequestAccessList); + readonly areRequestAccessListLoading = select(ContributorsSelectors.areRequestAccessListLoading); readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); readonly isViewOnlyLinksLoading = select(ViewOnlyLinkSelectors.isViewOnlyLinksLoading); readonly currentUser = select(UserSelectors.getCurrentUser); @@ -118,11 +125,19 @@ export class ContributorsComponent implements OnInit { const initialContributors = this.initialContributors(); if (!currentUserId) return false; - return initialContributors.some((contributor: ContributorModel) => { - return contributor.userId === currentUserId && contributor.permission === ContributorPermission.Admin; - }); + return initialContributors.some( + (contributor: ContributorModel) => + contributor.userId === currentUserId && contributor.permission === ContributorPermission.Admin + ); }); + showRequestAccessList = computed( + () => + this.isCurrentUserAdminContributor() && + this.requestAccessList().length && + this.resourceType() === ResourceType.Project + ); + actions = createDispatchMap({ getViewOnlyLinks: FetchViewOnlyLinks, getResourceDetails: GetResourceDetails, @@ -136,6 +151,9 @@ export class ContributorsComponent implements OnInit { addContributor: AddContributor, createViewOnlyLink: CreateViewOnlyLink, deleteViewOnlyLink: DeleteViewOnlyLink, + getRequestAccessContributors: GetRequestAccessContributors, + acceptRequestAccess: AcceptRequestAccess, + rejectRequestAccess: RejectRequestAccess, }); get hasChanges(): boolean { @@ -166,6 +184,10 @@ export class ContributorsComponent implements OnInit { if (id) { this.actions.getResourceDetails(id, this.resourceType()); this.actions.getContributors(id, this.resourceType()); + + if (this.resourceType() === ResourceType.Project) { + this.actions.getRequestAccessContributors(id, this.resourceType()); + } } this.setSearchSubscription(); @@ -250,6 +272,38 @@ export class ContributorsComponent implements OnInit { }); } + acceptRequest(contributor: ContributorModel) { + this.customConfirmationService.confirmAccept({ + headerKey: 'project.requestAccess.acceptDialog.header', + messageKey: 'project.requestAccess.acceptDialog.message', + messageParams: { name: contributor.fullName }, + acceptLabelKey: 'common.buttons.accept', + onConfirm: () => { + const payload = { permissions: contributor.permission }; + + this.actions + .acceptRequestAccess(contributor.id, this.resourceId(), this.resourceType(), payload) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.toastService.showSuccess('project.requestAccess.acceptDialog.successMessage')); + }, + }); + } + + rejectRequest(contributor: ContributorModel) { + this.customConfirmationService.confirmDelete({ + headerKey: 'project.requestAccess.rejectDialog.header', + messageKey: 'project.requestAccess.rejectDialog.message', + messageParams: { name: contributor.fullName }, + acceptLabelKey: 'common.buttons.reject', + onConfirm: () => { + this.actions + .rejectRequestAccess(contributor.id, this.resourceId(), this.resourceType()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.toastService.showSuccess('project.requestAccess.rejectDialog.successMessage')); + }, + }); + } + removeContributor(contributor: ContributorModel) { this.customConfirmationService.confirmDelete({ headerKey: 'project.contributors.removeDialog.title', diff --git a/src/app/shared/components/contributors/contributors-table/contributors-table.component.html b/src/app/shared/components/contributors/contributors-table/contributors-table.component.html index fc4505d43..03b457a95 100644 --- a/src/app/shared/components/contributors/contributors-table/contributors-table.component.html +++ b/src/app/shared/components/contributors/contributors-table/contributors-table.component.html @@ -9,7 +9,7 @@ [lazy]="true" [lazyLoadOnInit]="true" [customSort]="true" - [reorderableColumns]="true" + [reorderableColumns]="!deactivatedContributors()" (onRowReorder)="onRowReorder()" class="view-only-table" > @@ -176,7 +176,7 @@ } @else { - + @@ -185,7 +185,7 @@ - {{ 'project.contributors.table.emptyMessage' | translate }} + {{ 'project.contributors.table.emptyMessage' | translate }} diff --git a/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts b/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts index 6b56259eb..325c1225d 100644 --- a/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts +++ b/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts @@ -9,14 +9,14 @@ import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, computed, inject, input, model, output, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { EducationHistoryDialogComponent } from '@osf/shared/components/education-history-dialog/education-history-dialog.component'; -import { EmploymentHistoryDialogComponent } from '@osf/shared/components/employment-history-dialog/employment-history-dialog.component'; import { SelectComponent } from '@osf/shared/components/select/select.component'; import { DEFAULT_TABLE_PARAMS, PERMISSION_OPTIONS } from '@osf/shared/constants'; import { ContributorPermission, ResourceType } from '@osf/shared/enums'; import { ContributorModel, SelectOption, TableParameters } from '@osf/shared/models'; import { CustomDialogService } from '@osf/shared/services'; +import { EducationHistoryDialogComponent } from '../../education-history-dialog/education-history-dialog.component'; +import { EmploymentHistoryDialogComponent } from '../../employment-history-dialog/employment-history-dialog.component'; import { IconComponent } from '../../icon/icon.component'; import { InfoIconComponent } from '../../info-icon/info-icon.component'; diff --git a/src/app/shared/components/contributors/index.ts b/src/app/shared/components/contributors/index.ts index 1c1ae3307..bd33928cd 100644 --- a/src/app/shared/components/contributors/index.ts +++ b/src/app/shared/components/contributors/index.ts @@ -1,3 +1,4 @@ export * from './add-contributor-dialog/add-contributor-dialog.component'; export * from './add-unregistered-contributor-dialog/add-unregistered-contributor-dialog.component'; export * from './contributors-table/contributors-table.component'; +export * from './request-access-table/request-access-table.component'; diff --git a/src/app/shared/components/contributors/request-access-table/request-access-table.component.html b/src/app/shared/components/contributors/request-access-table/request-access-table.component.html new file mode 100644 index 000000000..dfaa199a6 --- /dev/null +++ b/src/app/shared/components/contributors/request-access-table/request-access-table.component.html @@ -0,0 +1,247 @@ + + + + {{ 'project.contributors.table.headers.name' | translate }} + +
+ {{ 'project.contributors.table.headers.permissions' | translate }} + + @if (showInfo()) { + + } +
+ + + +
+ {{ 'project.contributors.table.headers.contributor' | translate }} + + @if (showInfo()) { + + } +
+ + + +
+ {{ 'project.contributors.table.headers.curator' | translate }} + + @if (showInfo()) { + + } +
+ + @if (showEmployment()) { + {{ 'project.contributors.table.headers.employment' | translate }} + } + @if (showEducation()) { + {{ 'project.contributors.table.headers.education' | translate }} + } + + +
+ + + @if (contributor.id) { + + +

+ {{ contributor.fullName }} +

+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + @if (showEmployment()) { + + @if (contributor.employment?.length) { + + } @else { + {{ 'project.contributors.employment.none' | translate }} + } + + } + @if (showEducation()) { + +
+ @if (contributor.education?.length) { + + } @else { + {{ 'project.contributors.education.none' | translate }} + } +
+ + } + + + + + + + } @else { + + + + + + } +
+ + + + {{ 'project.contributors.table.emptyMessage' | translate }} + + +
+ + +
+

{{ 'project.contributors.permissionInfo.title' | translate }}

+ +
+

{{ 'project.contributors.permissions.read' | translate }}

+
    +
  • + {{ + (isProject() + ? 'project.contributors.permissionInfo.viewProjectContent' + : 'project.contributors.permissionInfo.viewRegistrationContent' + ) | translate + }} +
  • +
+
+ +
+

{{ 'project.contributors.permissions.readAndWrite' | translate }}

+
    +
  • {{ 'project.contributors.permissionInfo.read' | translate }}
  • +
  • + {{ + (isProject() + ? 'project.contributors.permissionInfo.addComponents' + : 'project.contributors.permissionInfo.editMetadata' + ) | translate + }} +
  • +
  • + {{ + (isProject() + ? 'project.contributors.permissionInfo.editContent' + : 'project.contributors.permissionInfo.addResourcesLinks' + ) | translate + }} +
  • +
+
+ +
+

{{ 'project.contributors.permissions.administrator' | translate }}

+
    +
  • {{ 'project.contributors.permissionInfo.readWrite' | translate }}
  • +
  • {{ 'project.contributors.permissionInfo.manageContributors' | translate }}
  • +
  • + {{ + (isProject() + ? 'project.contributors.permissionInfo.deleteRegister' + : 'project.contributors.permissionInfo.withdrawRegistration' + ) | translate + }} +
  • +
  • + {{ + (isProject() + ? 'project.contributors.permissionInfo.publicPrivate' + : 'project.contributors.permissionInfo.endEmbargoEarly' + ) | translate + }} +
  • +
+
+
+
+ + +
+

{{ 'project.contributors.bibliographicContributorInfo.heading' | translate }}

+ + + {{ + (isProject() + ? 'project.contributors.bibliographicContributorInfo.projectDescription' + : 'project.contributors.bibliographicContributorInfo.registrationDescription' + ) | translate + }} + +
+
+ + +
+

{{ 'project.contributors.curatorInfo.heading' | translate }}

+ + {{ + (isProject() + ? 'project.contributors.curatorInfo.projectDescription' + : 'project.contributors.curatorInfo.registrationDescription' + ) | translate + }} +
+
diff --git a/src/app/shared/components/contributors/request-access-table/request-access-table.component.scss b/src/app/shared/components/contributors/request-access-table/request-access-table.component.scss new file mode 100644 index 000000000..0f1a07996 --- /dev/null +++ b/src/app/shared/components/contributors/request-access-table/request-access-table.component.scss @@ -0,0 +1,10 @@ +.blue-icon { + margin-top: 0.125rem; + cursor: pointer; +} + +.inside-list { + li { + list-style: inside; + } +} diff --git a/src/app/shared/components/contributors/request-access-table/request-access-table.component.spec.ts b/src/app/shared/components/contributors/request-access-table/request-access-table.component.spec.ts new file mode 100644 index 000000000..d5ec34514 --- /dev/null +++ b/src/app/shared/components/contributors/request-access-table/request-access-table.component.spec.ts @@ -0,0 +1,30 @@ +import { MockProviders } from 'ng-mocks'; + +import { DialogService } from 'primeng/dynamicdialog'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TranslateServiceMock } from '@osf/shared/mocks'; + +import { RequestAccessTableComponent } from './request-access-table.component'; + +describe.skip('RequestAccessTableComponent', () => { + let component: RequestAccessTableComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RequestAccessTableComponent], + providers: [MockProviders(DialogService), TranslateServiceMock], + }).compileComponents(); + + fixture = TestBed.createComponent(RequestAccessTableComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/contributors/request-access-table/request-access-table.component.ts b/src/app/shared/components/contributors/request-access-table/request-access-table.component.ts new file mode 100644 index 000000000..a12690960 --- /dev/null +++ b/src/app/shared/components/contributors/request-access-table/request-access-table.component.ts @@ -0,0 +1,70 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Checkbox } from 'primeng/checkbox'; +import { Skeleton } from 'primeng/skeleton'; +import { TableModule } from 'primeng/table'; +import { Tooltip } from 'primeng/tooltip'; + +import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { PERMISSION_OPTIONS } from '@osf/shared/constants'; +import { ResourceType } from '@osf/shared/enums'; +import { ContributorModel, SelectOption } from '@osf/shared/models'; +import { CustomDialogService } from '@osf/shared/services'; + +import { EducationHistoryDialogComponent } from '../../education-history-dialog/education-history-dialog.component'; +import { EmploymentHistoryDialogComponent } from '../../employment-history-dialog/employment-history-dialog.component'; +import { SelectComponent } from '../../select/select.component'; + +@Component({ + selector: 'osf-request-access-table', + imports: [TranslatePipe, TableModule, Tooltip, Skeleton, Button, SelectComponent, Checkbox, FormsModule], + templateUrl: './request-access-table.component.html', + styleUrl: './request-access-table.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RequestAccessTableComponent { + contributors = input.required(); + isLoading = input(false); + resourceType = input(ResourceType.Project); + showEmployment = input(true); + showEducation = input(true); + showInfo = input(true); + + accept = output(); + reject = output(); + + customDialogService = inject(CustomDialogService); + + readonly permissionsOptions: SelectOption[] = PERMISSION_OPTIONS; + + skeletonData: ContributorModel[] = Array.from({ length: 3 }, () => ({}) as ContributorModel); + + isProject = computed(() => this.resourceType() === ResourceType.Project); + + acceptContributor(contributor: ContributorModel) { + this.accept.emit(contributor); + } + + rejectContributor(contributor: ContributorModel) { + this.reject.emit(contributor); + } + + openEducationHistory(contributor: ContributorModel) { + this.customDialogService.open(EducationHistoryDialogComponent, { + header: 'project.contributors.table.headers.education', + width: '552px', + data: contributor.education, + }); + } + + openEmploymentHistory(contributor: ContributorModel) { + this.customDialogService.open(EmploymentHistoryDialogComponent, { + header: 'project.contributors.table.headers.employment', + width: '552px', + data: contributor.employment, + }); + } +} diff --git a/src/app/shared/enums/index.ts b/src/app/shared/enums/index.ts index 301cfef44..196709f99 100644 --- a/src/app/shared/enums/index.ts +++ b/src/app/shared/enums/index.ts @@ -26,6 +26,7 @@ export * from './profile-settings-key.enum'; export * from './registration-review-states.enum'; export * from './registry-resource.enum'; export * from './registry-status.enum'; +export * from './request-access-trigger.enum'; export * from './resource-search-mode.enum'; export * from './resource-type.enum'; export * from './reusable-filter-type.enum'; diff --git a/src/app/shared/enums/request-access-trigger.enum.ts b/src/app/shared/enums/request-access-trigger.enum.ts new file mode 100644 index 000000000..507d79d9b --- /dev/null +++ b/src/app/shared/enums/request-access-trigger.enum.ts @@ -0,0 +1,6 @@ +export enum RequestAccessTrigger { + Submit = 'submit', + Accept = 'accept', + Reject = 'reject', + EditComment = 'edit_comment', +} diff --git a/src/app/shared/mappers/request-access/index.ts b/src/app/shared/mappers/request-access/index.ts new file mode 100644 index 000000000..46ff14329 --- /dev/null +++ b/src/app/shared/mappers/request-access/index.ts @@ -0,0 +1 @@ +export * from './request-access.mapper'; diff --git a/src/app/shared/mappers/request-access/request-access.mapper.ts b/src/app/shared/mappers/request-access/request-access.mapper.ts new file mode 100644 index 000000000..ec6fe2bb6 --- /dev/null +++ b/src/app/shared/mappers/request-access/request-access.mapper.ts @@ -0,0 +1,82 @@ +import { ContributorPermission, RequestAccessTrigger } from '@osf/shared/enums'; +import { + ContributorModel, + RequestAccessDataJsonApi, + RequestAccessModel, + RequestAccessPayload, +} from '@osf/shared/models'; + +import { UserMapper } from '../user'; + +export class RequestAccessMapper { + static getRequestAccessList(data: RequestAccessDataJsonApi[]): RequestAccessModel[] { + if (!data) { + return []; + } + + return data.map((item) => this.getRequestAccessItem(item)); + } + + static getRequestAccessItem(data: RequestAccessDataJsonApi): RequestAccessModel { + const attributes = data.attributes; + + return { + id: data.id, + requestType: attributes.machine_state, + machineState: attributes.machine_state, + comment: attributes.comment, + created: attributes.created, + modified: attributes.modified, + dateLastTransitioned: attributes.date_last_transitioned, + requestedPermissions: attributes.requested_permissions, + creator: UserMapper.fromUserGetResponse(data.embeds.creator.data), + }; + } + + static convertToContributorModels(data: RequestAccessDataJsonApi[]): ContributorModel[] { + return data.map((item, index) => this.convertToContributorModel(item, index)); + } + + static convertToContributorModel(data: RequestAccessDataJsonApi, index: number): ContributorModel { + const userData = data.embeds.creator.data; + const attributes = data.attributes; + + return { + id: data.id, + type: data.type, + isBibliographic: true, + isUnregisteredContributor: false, + isCurator: false, + permission: attributes.requested_permissions || ContributorPermission.Read, + index: index, + userId: userData.id || '', + fullName: userData?.attributes?.full_name || '', + givenName: userData?.attributes?.given_name || '', + familyName: userData?.attributes?.family_name || '', + education: userData?.attributes?.education || [], + employment: userData?.attributes?.employment || [], + deactivated: false, + }; + } + + static convertToRequestAccessAction(id: string, trigger: RequestAccessTrigger, payload?: RequestAccessPayload) { + return { + data: { + attributes: { + trigger, + permissions: payload?.permissions, + visible: true, + }, + relationships: { + target: { + data: { + type: 'node-requests', + id, + }, + }, + }, + type: 'node-request-actions', + }, + }; + } +} diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts index 0a8aefecc..857f27ac7 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -36,6 +36,7 @@ export * from './provider'; export * from './query-params.model'; export * from './regions'; export * from './registration'; +export * from './request-access'; export * from './resource-metadata.model'; export * from './resource-overview.model'; export * from './search'; diff --git a/src/app/shared/models/request-access/index.ts b/src/app/shared/models/request-access/index.ts new file mode 100644 index 000000000..6cdbb5f96 --- /dev/null +++ b/src/app/shared/models/request-access/index.ts @@ -0,0 +1,3 @@ +export * from './request-access.model'; +export * from './request-access-json-api.model'; +export * from './request-access-payload.model'; diff --git a/src/app/shared/models/request-access/request-access-json-api.model.ts b/src/app/shared/models/request-access/request-access-json-api.model.ts new file mode 100644 index 000000000..2e6e6a5b9 --- /dev/null +++ b/src/app/shared/models/request-access/request-access-json-api.model.ts @@ -0,0 +1,29 @@ +import { ContributorPermission } from '@osf/shared/enums'; + +import { ResponseJsonApi } from '../common'; +import { UserDataJsonApi } from '../user'; + +export type RequestAccessResponseJsonApi = ResponseJsonApi; + +export interface RequestAccessDataJsonApi { + id: string; + type: 'node-requests'; + attributes: RequestAccessAttributesJsonApi; + embeds: RequestAccessEmbedsJsonApi; +} + +export interface RequestAccessAttributesJsonApi { + request_type: string; + machine_state: string; + comment: string; + created: string; + modified: string; + date_last_transitioned: string; + requested_permissions: ContributorPermission | null; +} + +export interface RequestAccessEmbedsJsonApi { + creator: { + data: UserDataJsonApi; + }; +} diff --git a/src/app/shared/models/request-access/request-access-payload.model.ts b/src/app/shared/models/request-access/request-access-payload.model.ts new file mode 100644 index 000000000..d1617aed2 --- /dev/null +++ b/src/app/shared/models/request-access/request-access-payload.model.ts @@ -0,0 +1,5 @@ +import { ContributorPermission } from '@osf/shared/enums'; + +export interface RequestAccessPayload { + permissions: ContributorPermission; +} diff --git a/src/app/shared/models/request-access/request-access.model.ts b/src/app/shared/models/request-access/request-access.model.ts new file mode 100644 index 000000000..cfabba4a5 --- /dev/null +++ b/src/app/shared/models/request-access/request-access.model.ts @@ -0,0 +1,13 @@ +import { UserModel } from '../user'; + +export interface RequestAccessModel { + id: string; + requestType: string; + machineState: string; + comment: string; + created: string; + modified: string; + dateLastTransitioned: string; + requestedPermissions: string | null; + creator: UserModel; +} diff --git a/src/app/shared/services/contributors.service.ts b/src/app/shared/services/contributors.service.ts index fdeb8a157..1ed2514a4 100644 --- a/src/app/shared/services/contributors.service.ts +++ b/src/app/shared/services/contributors.service.ts @@ -55,6 +55,16 @@ export class ContributorsService { .pipe(map((response) => ContributorsMapper.getContributors(response.data))); } + getRequestAccessContributors(resourceType: ResourceType, resourceId: string): Observable { + const resourcePath = this.urlMap.get(resourceType); + const baseUrl = `${this.apiUrl}/${resourcePath}/${resourceId}/requests/`; + const params = { 'embed[]': ['creator'] }; + + return this.jsonApiService + .get(baseUrl, params) + .pipe(map((response) => ContributorsMapper.getContributors(response.data))); + } + searchUsers(value: string, page = 1): Observable> { const baseUrl = `${this.apiUrl}/users/?filter[full_name]=${value}&page=${page}`; diff --git a/src/app/shared/services/index.ts b/src/app/shared/services/index.ts index fca7a1f66..0f52b0dd9 100644 --- a/src/app/shared/services/index.ts +++ b/src/app/shared/services/index.ts @@ -23,6 +23,7 @@ export { NodeLinksService } from './node-links.service'; export { ProjectRedirectDialogService } from './project-redirect-dialog.service'; export { RegionsService } from './regions.service'; export { RegistrationProviderService } from './registration-provider.service'; +export { RequestAccessService } from './request-access.service'; export { ResourceGuidService } from './resource.service'; export { ResourceCardService } from './resource-card.service'; export { SocialShareService } from './social-share.service'; diff --git a/src/app/shared/services/request-access.service.ts b/src/app/shared/services/request-access.service.ts new file mode 100644 index 000000000..effa0ae8f --- /dev/null +++ b/src/app/shared/services/request-access.service.ts @@ -0,0 +1,83 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; + +import { RequestAccessTrigger, ResourceType } from '../enums'; +import { RequestAccessMapper } from '../mappers/request-access'; +import { ContributorModel, RequestAccessPayload, RequestAccessResponseJsonApi } from '../models'; + +import { JsonApiService } from './json-api.service'; + +@Injectable({ + providedIn: 'root', +}) +export class RequestAccessService { + private readonly jsonApiService = inject(JsonApiService); + private readonly environment = inject(ENVIRONMENT); + + get apiUrl() { + return `${this.environment.apiDomainUrl}/v2`; + } + + private readonly urlMap = new Map([ + [ResourceType.Project, 'nodes'], + [ResourceType.Registration, 'registrations'], + [ResourceType.Preprint, 'preprints'], + [ResourceType.DraftRegistration, 'draft_registrations'], + ]); + + private getBaseUrl(resourceType: ResourceType, resourceId: string): string { + const resourcePath = this.urlMap.get(resourceType); + + return `${this.apiUrl}/${resourcePath}/${resourceId}/requests`; + } + + getRequestAccessList(resourceType: ResourceType, resourceId: string): Observable { + const baseUrl = this.getBaseUrl(resourceType, resourceId); + const params = { 'embed[]': ['creator'], 'filter[machine_state]': 'pending' }; + + return this.jsonApiService + .get(`${baseUrl}/`, params) + .pipe(map((response) => RequestAccessMapper.convertToContributorModels(response.data))); + } + + acceptRequestAccess( + resourceType: ResourceType, + requestAccessId: string, + payload: RequestAccessPayload + ): Observable { + const resourcePath = this.urlMap.get(resourceType); + const baseUrl = `${this.apiUrl}/actions/requests/${resourcePath}/`; + const body = RequestAccessMapper.convertToRequestAccessAction( + requestAccessId, + RequestAccessTrigger.Accept, + payload + ); + + return this.jsonApiService.post(`${baseUrl}`, body); + } + + rejectRequestAccess(resourceType: ResourceType, requestAccessId: string): Observable { + const resourcePath = this.urlMap.get(resourceType); + const baseUrl = `${this.apiUrl}/actions/requests/${resourcePath}/`; + const body = RequestAccessMapper.convertToRequestAccessAction(requestAccessId, RequestAccessTrigger.Reject); + + return this.jsonApiService.post(`${baseUrl}`, body); + } + + requestAccessToProject(resourceId: string, comment = ''): Observable { + const payload = { + data: { + attributes: { + comment, + request_type: 'access', + }, + type: 'node-requests', + }, + }; + + return this.jsonApiService.post(`${this.apiUrl}/nodes/${resourceId}/requests/`, payload); + } +} diff --git a/src/app/shared/stores/contributors/contributors.actions.ts b/src/app/shared/stores/contributors/contributors.actions.ts index 8633da276..85421d206 100644 --- a/src/app/shared/stores/contributors/contributors.actions.ts +++ b/src/app/shared/stores/contributors/contributors.actions.ts @@ -1,5 +1,5 @@ import { ResourceType } from '@osf/shared/enums'; -import { ContributorAddModel, ContributorModel } from '@osf/shared/models'; +import { ContributorAddModel, ContributorModel, RequestAccessPayload } from '@osf/shared/models'; export class GetAllContributors { static readonly type = '[Contributors] Get All Contributors'; @@ -84,3 +84,33 @@ export class ClearUsers { export class ResetContributorsState { static readonly type = '[Contributors] Reset State'; } + +export class GetRequestAccessContributors { + static readonly type = '[Contributors] Get Request Access Contributors'; + + constructor( + public resourceId: string | undefined | null, + public resourceType: ResourceType | undefined + ) {} +} + +export class AcceptRequestAccess { + static readonly type = '[Contributors] Accept Request Access'; + + constructor( + public requestId: string | undefined | null, + public resourceId: string | undefined | null, + public resourceType: ResourceType | undefined, + public payload: RequestAccessPayload + ) {} +} + +export class RejectRequestAccess { + static readonly type = '[Contributors] Reject Request Access'; + + constructor( + public requestId: string | undefined | null, + public resourceId: string | undefined | null, + public resourceType: ResourceType | undefined + ) {} +} diff --git a/src/app/shared/stores/contributors/contributors.model.ts b/src/app/shared/stores/contributors/contributors.model.ts index 34baade41..43b1a982a 100644 --- a/src/app/shared/stores/contributors/contributors.model.ts +++ b/src/app/shared/stores/contributors/contributors.model.ts @@ -7,6 +7,7 @@ export interface ContributorsStateModel { permissionFilter: string | null; bibliographyFilter: boolean | null; }; + requestAccessList: AsyncStateModel; users: AsyncStateWithTotalCount; } @@ -19,6 +20,11 @@ export const CONTRIBUTORS_STATE_DEFAULTS: ContributorsStateModel = { permissionFilter: null, bibliographyFilter: null, }, + requestAccessList: { + data: [], + isLoading: false, + error: null, + }, users: { data: [], isLoading: false, diff --git a/src/app/shared/stores/contributors/contributors.selectors.ts b/src/app/shared/stores/contributors/contributors.selectors.ts index e49f044d8..e1f9cadea 100644 --- a/src/app/shared/stores/contributors/contributors.selectors.ts +++ b/src/app/shared/stores/contributors/contributors.selectors.ts @@ -66,4 +66,18 @@ export class ContributorsSelectors { static isUsersError(state: ContributorsStateModel) { return !!state?.users?.error?.length; } + + @Selector([ContributorsState]) + static getRequestAccessList(state: ContributorsStateModel) { + if (!state?.requestAccessList?.data) { + return []; + } + + return state.requestAccessList.data; + } + + @Selector([ContributorsState]) + static areRequestAccessListLoading(state: ContributorsStateModel) { + return state.requestAccessList.isLoading; + } } diff --git a/src/app/shared/stores/contributors/contributors.state.ts b/src/app/shared/stores/contributors/contributors.state.ts index fbef29ec8..8d8f964dc 100644 --- a/src/app/shared/stores/contributors/contributors.state.ts +++ b/src/app/shared/stores/contributors/contributors.state.ts @@ -5,15 +5,18 @@ import { catchError, of, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers'; -import { ContributorsService } from '@osf/shared/services'; +import { ContributorsService, RequestAccessService } from '@osf/shared/services'; import { + AcceptRequestAccess, AddContributor, BulkAddContributors, BulkUpdateContributors, ClearUsers, DeleteContributor, GetAllContributors, + GetRequestAccessContributors, + RejectRequestAccess, ResetContributorsState, SearchUsers, UpdateBibliographyFilter, @@ -29,6 +32,7 @@ import { CONTRIBUTORS_STATE_DEFAULTS, ContributorsStateModel } from './contribut @Injectable() export class ContributorsState { private readonly contributorsService = inject(ContributorsService); + private readonly requestAccessService = inject(RequestAccessService); @Action(GetAllContributors) getAllContributors(ctx: StateContext, action: GetAllContributors) { @@ -56,6 +60,84 @@ export class ContributorsState { ); } + @Action(GetRequestAccessContributors) + getRequestAccessContributors(ctx: StateContext, action: GetRequestAccessContributors) { + const state = ctx.getState(); + + if (!action.resourceId || !action.resourceType) { + return; + } + + ctx.patchState({ + requestAccessList: { ...state.requestAccessList, data: [], isLoading: true, error: null }, + }); + + return this.requestAccessService.getRequestAccessList(action.resourceType, action.resourceId).pipe( + tap((requestAccessList) => { + ctx.patchState({ + requestAccessList: { + ...state.requestAccessList, + data: requestAccessList, + isLoading: false, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'requestAccessList', error)) + ); + } + + @Action(AcceptRequestAccess) + acceptRequestAccess(ctx: StateContext, action: AcceptRequestAccess) { + const state = ctx.getState(); + + if (!action.requestId || !action.resourceType) { + return; + } + + ctx.patchState({ + requestAccessList: { data: [], isLoading: true, error: null }, + }); + + return this.requestAccessService.acceptRequestAccess(action.resourceType, action.requestId, action.payload).pipe( + tap(() => { + const dataList = state.requestAccessList.data.filter((item) => item.id !== action.requestId); + + ctx.dispatch(new GetAllContributors(action.resourceId, action.resourceType)); + + ctx.patchState({ + requestAccessList: { data: dataList, isLoading: false, error: null }, + }); + }), + catchError((error) => handleSectionError(ctx, 'requestAccessList', error)) + ); + } + + @Action(RejectRequestAccess) + rejectRequestAccess(ctx: StateContext, action: RejectRequestAccess) { + const state = ctx.getState(); + + if (!action.requestId || !action.resourceType) { + return; + } + + ctx.patchState({ + requestAccessList: { data: [], isLoading: true, error: null }, + }); + + return this.requestAccessService.rejectRequestAccess(action.resourceType, action.requestId).pipe( + tap(() => { + const dataList = state.requestAccessList.data.filter((item) => item.id !== action.requestId); + + ctx.dispatch(new GetAllContributors(action.resourceId, action.resourceType)); + + ctx.patchState({ + requestAccessList: { data: dataList, isLoading: false, error: null }, + }); + }), + catchError((error) => handleSectionError(ctx, 'requestAccessList', error)) + ); + } + @Action(AddContributor) addContributor(ctx: StateContext, action: AddContributor) { const state = ctx.getState(); diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index bdef66c9f..bf213b6f6 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -52,7 +52,9 @@ "addOneMore": "Add One More", "filter": "Filter", "sort": "Sort", - "replace": "Replace" + "replace": "Replace", + "accept": "Accept", + "reject": "Reject" }, "accessibility": { "help": "Help", @@ -626,6 +628,20 @@ "multipleUpdateSuccessMessage": "Contributors successfully updated." } }, + "requestAccess": { + "requestForAccess": "Requests for Access", + "followingUsers": "The following users have requested access to this project.", + "acceptDialog": { + "header": "Accept access", + "message": "Are you sure you want to accept access for {{name}}?", + "successMessage": "Access accepted successfully." + }, + "rejectDialog": { + "header": "Reject access", + "message": "Are you sure you want to reject access for {{name}}?", + "successMessage": "Access rejected successfully." + } + }, "overview": { "header": { "privateProject": "Private Project",